From e3537988f383c515d5fecd731456d0e50f5a052a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Nozi=C3=A8re?= Date: Fri, 28 Aug 2026 14:09:29 +0200 Subject: [PATCH 1/3] docs: document the personal data commands and the retention policy --- docs/reference/cli/customer_anonymize.md | 48 +++++ .../cli/customer_export_personal_data.md | 40 ++++ docs/reference/cli/index.md | 30 ++- docs/reference/cli/maintenance_purge.md | 60 ++++++ docs/security/personal-data.md | 180 ++++++++++++++++++ .../reference/cli/customer_anonymize.md | 48 +++++ .../cli/customer_export_personal_data.md | 40 ++++ .../version-3.0/reference/cli/index.md | 30 ++- .../reference/cli/maintenance_purge.md | 60 ++++++ .../version-3.0/security/_category_.json | 7 + .../version-3.0/security/personal-data.md | 180 ++++++++++++++++++ .../version-3.0/security/security-policy.md | 108 +++++++++++ 12 files changed, 821 insertions(+), 10 deletions(-) create mode 100644 docs/reference/cli/customer_anonymize.md create mode 100644 docs/reference/cli/customer_export_personal_data.md create mode 100644 docs/reference/cli/maintenance_purge.md create mode 100644 docs/security/personal-data.md create mode 100644 versioned_docs/version-3.0/reference/cli/customer_anonymize.md create mode 100644 versioned_docs/version-3.0/reference/cli/customer_export_personal_data.md create mode 100644 versioned_docs/version-3.0/reference/cli/maintenance_purge.md create mode 100644 versioned_docs/version-3.0/security/_category_.json create mode 100644 versioned_docs/version-3.0/security/personal-data.md create mode 100644 versioned_docs/version-3.0/security/security-policy.md diff --git a/docs/reference/cli/customer_anonymize.md b/docs/reference/cli/customer_anonymize.md new file mode 100644 index 00000000..3abd277f --- /dev/null +++ b/docs/reference/cli/customer_anonymize.md @@ -0,0 +1,48 @@ +--- +title: customer:anonymize +--- + +## Description +Erase the identifying data of a customer, keeping the accounting record of the orders. + +## Usage +```shell + customer:anonymize [options] +``` + +## Arguments + - `email` Email address of the customer to anonymize. + +## Options + - `--force` Do not ask for confirmation. + +Deleting an account would take away orders a business is required to keep. This command +erases the identity instead: name, email, password, tokens, address book, cart addresses, +the identity frozen on the order addresses (including SIRET and VAT number), carts, +newsletter subscription, account version history, and the identity copied into the admin +log. The orders keep their reference, invoice number and date, amounts, taxes, coupons and +status history, and stay attached to the now anonymous account. + +The whole operation runs in a single Propel transaction: if a module fails, nothing is +written. `customer.anonymized_at` records the date of the first erasure, so running the +command twice does not move the date. + +Modules erase their own share by implementing `CustomerPersonalDataProviderInterface`. See +[Personal data](../../security/personal-data.md). + +## Examples +Ask for confirmation, then anonymize: +```shell +php Thelia customer:anonymize customer@example.com +``` + +From a script, without the prompt: +```shell +php Thelia customer:anonymize customer@example.com --force +``` + +:::caution +This cannot be undone. The account is disabled and its email is replaced by +`anonymous-@anonymous.invalid`, so the person cannot log in again nor be found by +email. +::: diff --git a/docs/reference/cli/customer_export_personal_data.md b/docs/reference/cli/customer_export_personal_data.md new file mode 100644 index 00000000..c0b1b1bc --- /dev/null +++ b/docs/reference/cli/customer_export_personal_data.md @@ -0,0 +1,40 @@ +--- +title: customer:export-personal-data +--- + +## Description +Export everything the shop knows about one customer, as JSON. + +## Usage +```shell + customer:export-personal-data [options] +``` + +## Arguments + - `email` Email address of the customer. + +## Options + - `--output-file[=OUTPUT-FILE]` Write the archive to this file instead of the standard output. + +Core contributes five sections: `customer` (the account), `addresses` (the address book), +`orders` (with their frozen order addresses, products and coupons), `carts`, and +`newsletter`. Every module implementing `CustomerPersonalDataProviderInterface` adds its +own section, under the name it declares. + +The same export is available from the back-office, on the customer sheet. + +## Examples +Print the export on screen: +```shell +php Thelia customer:export-personal-data customer@example.com +``` + +Write it to a file: +```shell +php Thelia customer:export-personal-data customer@example.com --output-file=export.json +``` + +:::tip +The file contains personal data. Deliver it over a channel the person controls, and delete +your local copy once it has been handed over. See [Personal data](../../security/personal-data.md). +::: diff --git a/docs/reference/cli/index.md b/docs/reference/cli/index.md index 36044992..1761c0d7 100644 --- a/docs/reference/cli/index.md +++ b/docs/reference/cli/index.md @@ -44,12 +44,14 @@ All commands below live in `Thelia\Command\` (core: `core/lib/Thelia/Command/`). | Command | Description | | --- | --- | -| `cache:clear` | Invalidate all caches. | +| `thelia:cache:clear` | Invalidate the application, assets, image and document caches. | | `image-cache:clear` | Empty part or all of the web-space image cache. | +| `hook:clean` | Delete all hooks, then recreate them from the module declarations. | :::note -`cache:clear` empties the Symfony cache; `image-cache:clear` empties the images generated in the web -space. Clearing one does not clear the other. +`thelia:cache:clear` empties the Thelia caches; `image-cache:clear` empties the images generated in +the web space. Clearing one does not clear the other. Symfony's own `cache:clear` is also available +through `php Thelia`, and empties the Symfony cache only. ::: ### Installation and database @@ -88,14 +90,32 @@ space. Clearing one does not clear the other. | Command | Description | | --- | --- | | `thelia:config` | Manage configuration variables. | -| `maintenance:purge` | Purge old data: carts without orders, anonymous carts, and admin logs. | +| [`maintenance:purge`](./maintenance_purge.md) | Purge old data: carts without orders, anonymous carts, admin logs, form firewall records, and the identity of accounts nobody uses anymore. | | `sale:check-activation` | Check the activation/deactivation dates of sales and apply the required action. | | `currency:update-rates` | Update currency exchange rates. | +| `thelia:order:rounding-mode` | Show or switch how order line totals are rounded, freezing the orders already placed. | +| `sequence:set` | Set a gapless sequence counter (`order_ref`, `invoice_ref_`, ...) to a given value. | +| `import-export:clean` | Delete the exports and imports whose handler class is no longer available. | + +`thelia:order:rounding-mode` writes a pivot in the same run, so the orders already invoiced keep the +amounts they were invoiced with. `sequence:set` is what you reach for when a shop moves to Thelia +with an existing invoice numbering to continue. :::note -`maintenance:purge` still declares its name with `setName()` in `configure()` rather than the `#[AsCommand]` attribute. Both styles work; new commands should use the attribute. +`maintenance:purge`, `customer:anonymize` and `customer:export-personal-data` still declare their name with `setName()` in `configure()` rather than the `#[AsCommand]` attribute. Both styles work; new commands should use the attribute. ::: +### Customers and personal data + +| Command | Description | +| --- | --- | +| [`customer:anonymize`](./customer_anonymize.md) | Erase the identifying data of a customer, keeping the accounting record of the orders. | +| [`customer:export-personal-data`](./customer_export_personal_data.md) | Export everything the shop knows about one customer, as JSON. | + +These two commands answer a right of access and a right to erasure. Both are also available from the +back-office, on the customer sheet, and both call the modules that declare personal data. See +[Personal data](../../security/personal-data.md). + ### Templates, e-mail, PDF and i18n | Command | Description | diff --git a/docs/reference/cli/maintenance_purge.md b/docs/reference/cli/maintenance_purge.md new file mode 100644 index 00000000..2493710e --- /dev/null +++ b/docs/reference/cli/maintenance_purge.md @@ -0,0 +1,60 @@ +--- +title: maintenance:purge +--- + +## Description +Purge old data from the database: carts without orders, anonymous carts, admin logs, form firewall records, and the identity of accounts nobody uses anymore. + +## Usage +```shell + maintenance:purge [options] +``` + +## Options + - `--dry-run` Report what the purge would remove, without touching anything. + +Every period is a configuration variable, so a shop sets its own. Read and write them with +`thelia:config`. + +| Data | Configuration variable | Default | +| --- | --- | --- | +| Carts without an order | `purification_cart_no_order_days` | 60 days | +| Anonymous carts | `purification_cart_anonymous_days` | 30 days | +| Admin logs | `purification_admin_logs_days` | 180 days | +| Form firewall records | `purification_form_firewall_days` | 1 day | +| Identity of accounts that never ordered | `purification_customer_no_order_days` | `0`, off | +| Identity of accounts whose last order is old | `purification_customer_after_last_order_days` | `0`, off | + +Customer retention is off by default, on purpose: erasing an identity cannot be undone, +and the shop is the only one that knows how long it is allowed to keep the data. When a +period is set, the accounts are anonymized through `CUSTOMER_ANONYMIZE`, so modules erase +their share on a scheduled run exactly as on a manual one, and an account already +anonymized is skipped. + +The form firewall threshold never goes below the longest waiting period configured, so a +purge cannot hand a blocked IP address a fresh set of attempts. + +At the end of its run the command dispatches `TheliaEvents::MAINTENANCE_PURGE`: a module +listens to it, purges its own tables, and appends a line to the report with +`$event->addResult()`. + +## Examples +See what would be removed: +```shell +php Thelia maintenance:purge --dry-run +``` + +Run the purge, typically from a nightly task: +```shell +php Thelia maintenance:purge +``` + +Set a retention period of two years for accounts that never ordered: +```shell +php Thelia thelia:config set purification_customer_no_order_days 730 +``` + +:::tip +Run `--dry-run` after every change of period, and read the counts before letting the task +run unattended. See [Personal data](../../security/personal-data.md). +::: diff --git a/docs/security/personal-data.md b/docs/security/personal-data.md new file mode 100644 index 00000000..bfc8f51d --- /dev/null +++ b/docs/security/personal-data.md @@ -0,0 +1,180 @@ +--- +title: Personal data +sidebar_position: 2 +--- + +# Personal data + +Thelia ships the tools a shop needs to answer a customer asking for their data, or for +its deletion: an export, an anonymization that keeps the accounting record, and a +retention policy. Modules plug into all three through a single interface. + +## Exporting what the shop knows about someone + +`customer:export-personal-data` writes, as JSON, everything the shop holds about one +customer: the account, the address book, the orders with their frozen order addresses +and products, the carts, and the newsletter subscription. + +```shell +php Thelia customer:export-personal-data customer@example.com --output-file=export.json +``` + +The same export is available from the back-office, on the customer sheet. There, the +archive is streamed in the response: it is never written under the web root nor into a +shared cache, the response is marked `private, no-store, must-revalidate`, and the admin +log records the export without writing the customer name back into the database. + +See [`customer:export-personal-data`](../reference/cli/customer_export_personal_data.md). + +## Anonymizing instead of deleting + +Deleting an account would take away orders a business is required to keep. Anonymization +erases the identity and leaves the accounting record intact. + +| Erased | Kept | +| --- | --- | +| Account identity: last name, first name, email (replaced by `anonymous-@anonymous.invalid`), password, remember-me and confirmation tokens, sponsor. The account is disabled. | Orders: reference, invoice number and date, amounts, taxes, coupons, status history | +| Address book (`address`) and cart addresses (`cart_address`) | Country and state of the order address, which justify the VAT rate applied | +| Identity frozen on the order addresses (`order_address`): title, company, SIRET, VAT number, name, address, postcode, city, phone numbers | The order itself, still attached to the now anonymous account | +| Carts, newsletter subscription, account version history (`customer_version`) | Admin log entries: which administrator did what, and when | +| Identity copied into the admin log: the message and the posted request payload | | + +```shell +php Thelia customer:anonymize customer@example.com +``` + +The whole operation runs in a single Propel transaction: if a module fails, nothing is +written. `customer.anonymized_at` records the date of the first erasure, which makes the +operation traceable and idempotent: running it twice does not move the date. + +See [`customer:anonymize`](../reference/cli/customer_anonymize.md). + +:::note Deleting an account is still possible +`CUSTOMER_DELETEACCOUNT` and the back-office delete button are unchanged. Anonymization is +the alternative for a shop that must keep its invoices. +::: + +## Declaring the personal data a module holds + +A shop rarely runs core alone. A loyalty balance, a support ticket or a stored payment +token belong to the module that stores them. Implement +`Thelia\Domain\Customer\Service\CustomerPersonalDataProviderInterface` and the module is +called automatically on both the export and the anonymization: no configuration, no tag +to declare. + +```php +// local/modules/Loyalty/Service/LoyaltyPersonalData.php +filterByCustomerId($customer->getId()) + ->find() + ->toArray(); + } + + public function anonymizePersonalData(Customer $customer): void + { + LoyaltyPointQuery::create() + ->filterByCustomerId($customer->getId()) + ->delete(); + } +} +``` + +Three rules apply: + +- The section name must not collide with a core section (`customer`, `addresses`, + `orders`, `carts`, `newsletter`) nor with another provider. Prefer the module code. +- `exportPersonalData()` returns a structure suitable for JSON serialization, or an empty + array when there is nothing. +- `anonymizePersonalData()` runs inside the anonymization transaction. Throwing rolls the + whole operation back, including what core already anonymized. + +The provider is also called when the retention policy anonymizes a dormant account, so a +module erases its share on a scheduled run exactly as on a manual one. + +## Retention: not keeping what is no longer needed + +`maintenance:purge` deletes stale data. Every period is a configuration variable, so a +shop sets its own. + +| Data | Configuration variable | Default | +| --- | --- | --- | +| Carts without an order | `purification_cart_no_order_days` | 60 days | +| Anonymous carts | `purification_cart_anonymous_days` | 30 days | +| Admin logs | `purification_admin_logs_days` | 180 days | +| Form firewall records (they hold IP addresses) | `purification_form_firewall_days` | 1 day | +| Identity of accounts that never ordered | `purification_customer_no_order_days` | `0`, off | +| Identity of accounts whose last order is old | `purification_customer_after_last_order_days` | `0`, off | + +```shell +php Thelia maintenance:purge --dry-run +php Thelia maintenance:purge +``` + +:::caution Customer retention is off by default, on purpose +Erasing an identity cannot be undone, and the shop is the only one that knows how long it +is allowed to keep the data. Both customer periods default to `0`, which keeps the +retention off. Set them explicitly, and run `--dry-run` first. +::: + +The two customer periods are distinct because an account that has ordered is also tied to +an accounting retention obligation: an account that never ordered ages from its creation +date, an account that ordered ages from its last order. An account already anonymized is +skipped, so a nightly run does the work once per account. + +The form firewall threshold never goes below the longest waiting period configured, so a +purge cannot hand a blocked IP address a fresh set of attempts. + +See [`maintenance:purge`](../reference/cli/maintenance_purge.md). + +## Adding a module purge to the same run + +`maintenance:purge` dispatches `TheliaEvents::MAINTENANCE_PURGE` at the end of its run. A +module listens to it, purges its own tables, and appends a line to the report: + +```php +#[AsEventListener(event: TheliaEvents::MAINTENANCE_PURGE)] +public function purgeOldQuotes(MaintenancePurgeEvent $event): void +{ + $deleted = QuoteQuery::create() + ->filterByCreatedAt(new \DateTime('-1 year'), Criteria::LESS_THAN) + ->delete(); + + $event->addResult(sprintf('Quotes (>1 year): %d deleted', $deleted)); +} +``` + +## What Thelia does not provide + +To be explicit, so nobody promises it: + +- No cookie consent banner. It belongs to the theme or to a module. +- No self-service account deletion from the front-office. Both operations are available + from the CLI and the back-office only. +- Neither the export nor the anonymization is exposed through the API. +- No application-level encryption of personal columns at rest. + +## See also + +- [Security policy](./security-policy.md) +- [`customer:anonymize`](../reference/cli/customer_anonymize.md), + [`customer:export-personal-data`](../reference/cli/customer_export_personal_data.md), + [`maintenance:purge`](../reference/cli/maintenance_purge.md) diff --git a/versioned_docs/version-3.0/reference/cli/customer_anonymize.md b/versioned_docs/version-3.0/reference/cli/customer_anonymize.md new file mode 100644 index 00000000..3abd277f --- /dev/null +++ b/versioned_docs/version-3.0/reference/cli/customer_anonymize.md @@ -0,0 +1,48 @@ +--- +title: customer:anonymize +--- + +## Description +Erase the identifying data of a customer, keeping the accounting record of the orders. + +## Usage +```shell + customer:anonymize [options] +``` + +## Arguments + - `email` Email address of the customer to anonymize. + +## Options + - `--force` Do not ask for confirmation. + +Deleting an account would take away orders a business is required to keep. This command +erases the identity instead: name, email, password, tokens, address book, cart addresses, +the identity frozen on the order addresses (including SIRET and VAT number), carts, +newsletter subscription, account version history, and the identity copied into the admin +log. The orders keep their reference, invoice number and date, amounts, taxes, coupons and +status history, and stay attached to the now anonymous account. + +The whole operation runs in a single Propel transaction: if a module fails, nothing is +written. `customer.anonymized_at` records the date of the first erasure, so running the +command twice does not move the date. + +Modules erase their own share by implementing `CustomerPersonalDataProviderInterface`. See +[Personal data](../../security/personal-data.md). + +## Examples +Ask for confirmation, then anonymize: +```shell +php Thelia customer:anonymize customer@example.com +``` + +From a script, without the prompt: +```shell +php Thelia customer:anonymize customer@example.com --force +``` + +:::caution +This cannot be undone. The account is disabled and its email is replaced by +`anonymous-@anonymous.invalid`, so the person cannot log in again nor be found by +email. +::: diff --git a/versioned_docs/version-3.0/reference/cli/customer_export_personal_data.md b/versioned_docs/version-3.0/reference/cli/customer_export_personal_data.md new file mode 100644 index 00000000..c0b1b1bc --- /dev/null +++ b/versioned_docs/version-3.0/reference/cli/customer_export_personal_data.md @@ -0,0 +1,40 @@ +--- +title: customer:export-personal-data +--- + +## Description +Export everything the shop knows about one customer, as JSON. + +## Usage +```shell + customer:export-personal-data [options] +``` + +## Arguments + - `email` Email address of the customer. + +## Options + - `--output-file[=OUTPUT-FILE]` Write the archive to this file instead of the standard output. + +Core contributes five sections: `customer` (the account), `addresses` (the address book), +`orders` (with their frozen order addresses, products and coupons), `carts`, and +`newsletter`. Every module implementing `CustomerPersonalDataProviderInterface` adds its +own section, under the name it declares. + +The same export is available from the back-office, on the customer sheet. + +## Examples +Print the export on screen: +```shell +php Thelia customer:export-personal-data customer@example.com +``` + +Write it to a file: +```shell +php Thelia customer:export-personal-data customer@example.com --output-file=export.json +``` + +:::tip +The file contains personal data. Deliver it over a channel the person controls, and delete +your local copy once it has been handed over. See [Personal data](../../security/personal-data.md). +::: diff --git a/versioned_docs/version-3.0/reference/cli/index.md b/versioned_docs/version-3.0/reference/cli/index.md index 36044992..1761c0d7 100644 --- a/versioned_docs/version-3.0/reference/cli/index.md +++ b/versioned_docs/version-3.0/reference/cli/index.md @@ -44,12 +44,14 @@ All commands below live in `Thelia\Command\` (core: `core/lib/Thelia/Command/`). | Command | Description | | --- | --- | -| `cache:clear` | Invalidate all caches. | +| `thelia:cache:clear` | Invalidate the application, assets, image and document caches. | | `image-cache:clear` | Empty part or all of the web-space image cache. | +| `hook:clean` | Delete all hooks, then recreate them from the module declarations. | :::note -`cache:clear` empties the Symfony cache; `image-cache:clear` empties the images generated in the web -space. Clearing one does not clear the other. +`thelia:cache:clear` empties the Thelia caches; `image-cache:clear` empties the images generated in +the web space. Clearing one does not clear the other. Symfony's own `cache:clear` is also available +through `php Thelia`, and empties the Symfony cache only. ::: ### Installation and database @@ -88,14 +90,32 @@ space. Clearing one does not clear the other. | Command | Description | | --- | --- | | `thelia:config` | Manage configuration variables. | -| `maintenance:purge` | Purge old data: carts without orders, anonymous carts, and admin logs. | +| [`maintenance:purge`](./maintenance_purge.md) | Purge old data: carts without orders, anonymous carts, admin logs, form firewall records, and the identity of accounts nobody uses anymore. | | `sale:check-activation` | Check the activation/deactivation dates of sales and apply the required action. | | `currency:update-rates` | Update currency exchange rates. | +| `thelia:order:rounding-mode` | Show or switch how order line totals are rounded, freezing the orders already placed. | +| `sequence:set` | Set a gapless sequence counter (`order_ref`, `invoice_ref_`, ...) to a given value. | +| `import-export:clean` | Delete the exports and imports whose handler class is no longer available. | + +`thelia:order:rounding-mode` writes a pivot in the same run, so the orders already invoiced keep the +amounts they were invoiced with. `sequence:set` is what you reach for when a shop moves to Thelia +with an existing invoice numbering to continue. :::note -`maintenance:purge` still declares its name with `setName()` in `configure()` rather than the `#[AsCommand]` attribute. Both styles work; new commands should use the attribute. +`maintenance:purge`, `customer:anonymize` and `customer:export-personal-data` still declare their name with `setName()` in `configure()` rather than the `#[AsCommand]` attribute. Both styles work; new commands should use the attribute. ::: +### Customers and personal data + +| Command | Description | +| --- | --- | +| [`customer:anonymize`](./customer_anonymize.md) | Erase the identifying data of a customer, keeping the accounting record of the orders. | +| [`customer:export-personal-data`](./customer_export_personal_data.md) | Export everything the shop knows about one customer, as JSON. | + +These two commands answer a right of access and a right to erasure. Both are also available from the +back-office, on the customer sheet, and both call the modules that declare personal data. See +[Personal data](../../security/personal-data.md). + ### Templates, e-mail, PDF and i18n | Command | Description | diff --git a/versioned_docs/version-3.0/reference/cli/maintenance_purge.md b/versioned_docs/version-3.0/reference/cli/maintenance_purge.md new file mode 100644 index 00000000..2493710e --- /dev/null +++ b/versioned_docs/version-3.0/reference/cli/maintenance_purge.md @@ -0,0 +1,60 @@ +--- +title: maintenance:purge +--- + +## Description +Purge old data from the database: carts without orders, anonymous carts, admin logs, form firewall records, and the identity of accounts nobody uses anymore. + +## Usage +```shell + maintenance:purge [options] +``` + +## Options + - `--dry-run` Report what the purge would remove, without touching anything. + +Every period is a configuration variable, so a shop sets its own. Read and write them with +`thelia:config`. + +| Data | Configuration variable | Default | +| --- | --- | --- | +| Carts without an order | `purification_cart_no_order_days` | 60 days | +| Anonymous carts | `purification_cart_anonymous_days` | 30 days | +| Admin logs | `purification_admin_logs_days` | 180 days | +| Form firewall records | `purification_form_firewall_days` | 1 day | +| Identity of accounts that never ordered | `purification_customer_no_order_days` | `0`, off | +| Identity of accounts whose last order is old | `purification_customer_after_last_order_days` | `0`, off | + +Customer retention is off by default, on purpose: erasing an identity cannot be undone, +and the shop is the only one that knows how long it is allowed to keep the data. When a +period is set, the accounts are anonymized through `CUSTOMER_ANONYMIZE`, so modules erase +their share on a scheduled run exactly as on a manual one, and an account already +anonymized is skipped. + +The form firewall threshold never goes below the longest waiting period configured, so a +purge cannot hand a blocked IP address a fresh set of attempts. + +At the end of its run the command dispatches `TheliaEvents::MAINTENANCE_PURGE`: a module +listens to it, purges its own tables, and appends a line to the report with +`$event->addResult()`. + +## Examples +See what would be removed: +```shell +php Thelia maintenance:purge --dry-run +``` + +Run the purge, typically from a nightly task: +```shell +php Thelia maintenance:purge +``` + +Set a retention period of two years for accounts that never ordered: +```shell +php Thelia thelia:config set purification_customer_no_order_days 730 +``` + +:::tip +Run `--dry-run` after every change of period, and read the counts before letting the task +run unattended. See [Personal data](../../security/personal-data.md). +::: diff --git a/versioned_docs/version-3.0/security/_category_.json b/versioned_docs/version-3.0/security/_category_.json new file mode 100644 index 00000000..d6ab2841 --- /dev/null +++ b/versioned_docs/version-3.0/security/_category_.json @@ -0,0 +1,7 @@ +{ + "label": "Security", + "position": 12, + "customProps": { + "description": "How Thelia handles security vulnerabilities" + } +} diff --git a/versioned_docs/version-3.0/security/personal-data.md b/versioned_docs/version-3.0/security/personal-data.md new file mode 100644 index 00000000..bfc8f51d --- /dev/null +++ b/versioned_docs/version-3.0/security/personal-data.md @@ -0,0 +1,180 @@ +--- +title: Personal data +sidebar_position: 2 +--- + +# Personal data + +Thelia ships the tools a shop needs to answer a customer asking for their data, or for +its deletion: an export, an anonymization that keeps the accounting record, and a +retention policy. Modules plug into all three through a single interface. + +## Exporting what the shop knows about someone + +`customer:export-personal-data` writes, as JSON, everything the shop holds about one +customer: the account, the address book, the orders with their frozen order addresses +and products, the carts, and the newsletter subscription. + +```shell +php Thelia customer:export-personal-data customer@example.com --output-file=export.json +``` + +The same export is available from the back-office, on the customer sheet. There, the +archive is streamed in the response: it is never written under the web root nor into a +shared cache, the response is marked `private, no-store, must-revalidate`, and the admin +log records the export without writing the customer name back into the database. + +See [`customer:export-personal-data`](../reference/cli/customer_export_personal_data.md). + +## Anonymizing instead of deleting + +Deleting an account would take away orders a business is required to keep. Anonymization +erases the identity and leaves the accounting record intact. + +| Erased | Kept | +| --- | --- | +| Account identity: last name, first name, email (replaced by `anonymous-@anonymous.invalid`), password, remember-me and confirmation tokens, sponsor. The account is disabled. | Orders: reference, invoice number and date, amounts, taxes, coupons, status history | +| Address book (`address`) and cart addresses (`cart_address`) | Country and state of the order address, which justify the VAT rate applied | +| Identity frozen on the order addresses (`order_address`): title, company, SIRET, VAT number, name, address, postcode, city, phone numbers | The order itself, still attached to the now anonymous account | +| Carts, newsletter subscription, account version history (`customer_version`) | Admin log entries: which administrator did what, and when | +| Identity copied into the admin log: the message and the posted request payload | | + +```shell +php Thelia customer:anonymize customer@example.com +``` + +The whole operation runs in a single Propel transaction: if a module fails, nothing is +written. `customer.anonymized_at` records the date of the first erasure, which makes the +operation traceable and idempotent: running it twice does not move the date. + +See [`customer:anonymize`](../reference/cli/customer_anonymize.md). + +:::note Deleting an account is still possible +`CUSTOMER_DELETEACCOUNT` and the back-office delete button are unchanged. Anonymization is +the alternative for a shop that must keep its invoices. +::: + +## Declaring the personal data a module holds + +A shop rarely runs core alone. A loyalty balance, a support ticket or a stored payment +token belong to the module that stores them. Implement +`Thelia\Domain\Customer\Service\CustomerPersonalDataProviderInterface` and the module is +called automatically on both the export and the anonymization: no configuration, no tag +to declare. + +```php +// local/modules/Loyalty/Service/LoyaltyPersonalData.php +filterByCustomerId($customer->getId()) + ->find() + ->toArray(); + } + + public function anonymizePersonalData(Customer $customer): void + { + LoyaltyPointQuery::create() + ->filterByCustomerId($customer->getId()) + ->delete(); + } +} +``` + +Three rules apply: + +- The section name must not collide with a core section (`customer`, `addresses`, + `orders`, `carts`, `newsletter`) nor with another provider. Prefer the module code. +- `exportPersonalData()` returns a structure suitable for JSON serialization, or an empty + array when there is nothing. +- `anonymizePersonalData()` runs inside the anonymization transaction. Throwing rolls the + whole operation back, including what core already anonymized. + +The provider is also called when the retention policy anonymizes a dormant account, so a +module erases its share on a scheduled run exactly as on a manual one. + +## Retention: not keeping what is no longer needed + +`maintenance:purge` deletes stale data. Every period is a configuration variable, so a +shop sets its own. + +| Data | Configuration variable | Default | +| --- | --- | --- | +| Carts without an order | `purification_cart_no_order_days` | 60 days | +| Anonymous carts | `purification_cart_anonymous_days` | 30 days | +| Admin logs | `purification_admin_logs_days` | 180 days | +| Form firewall records (they hold IP addresses) | `purification_form_firewall_days` | 1 day | +| Identity of accounts that never ordered | `purification_customer_no_order_days` | `0`, off | +| Identity of accounts whose last order is old | `purification_customer_after_last_order_days` | `0`, off | + +```shell +php Thelia maintenance:purge --dry-run +php Thelia maintenance:purge +``` + +:::caution Customer retention is off by default, on purpose +Erasing an identity cannot be undone, and the shop is the only one that knows how long it +is allowed to keep the data. Both customer periods default to `0`, which keeps the +retention off. Set them explicitly, and run `--dry-run` first. +::: + +The two customer periods are distinct because an account that has ordered is also tied to +an accounting retention obligation: an account that never ordered ages from its creation +date, an account that ordered ages from its last order. An account already anonymized is +skipped, so a nightly run does the work once per account. + +The form firewall threshold never goes below the longest waiting period configured, so a +purge cannot hand a blocked IP address a fresh set of attempts. + +See [`maintenance:purge`](../reference/cli/maintenance_purge.md). + +## Adding a module purge to the same run + +`maintenance:purge` dispatches `TheliaEvents::MAINTENANCE_PURGE` at the end of its run. A +module listens to it, purges its own tables, and appends a line to the report: + +```php +#[AsEventListener(event: TheliaEvents::MAINTENANCE_PURGE)] +public function purgeOldQuotes(MaintenancePurgeEvent $event): void +{ + $deleted = QuoteQuery::create() + ->filterByCreatedAt(new \DateTime('-1 year'), Criteria::LESS_THAN) + ->delete(); + + $event->addResult(sprintf('Quotes (>1 year): %d deleted', $deleted)); +} +``` + +## What Thelia does not provide + +To be explicit, so nobody promises it: + +- No cookie consent banner. It belongs to the theme or to a module. +- No self-service account deletion from the front-office. Both operations are available + from the CLI and the back-office only. +- Neither the export nor the anonymization is exposed through the API. +- No application-level encryption of personal columns at rest. + +## See also + +- [Security policy](./security-policy.md) +- [`customer:anonymize`](../reference/cli/customer_anonymize.md), + [`customer:export-personal-data`](../reference/cli/customer_export_personal_data.md), + [`maintenance:purge`](../reference/cli/maintenance_purge.md) diff --git a/versioned_docs/version-3.0/security/security-policy.md b/versioned_docs/version-3.0/security/security-policy.md new file mode 100644 index 00000000..15a6c974 --- /dev/null +++ b/versioned_docs/version-3.0/security/security-policy.md @@ -0,0 +1,108 @@ +--- +title: Security Policy +sidebar_position: 1 +--- + +# Security Policy + +Thelia is free and open source software stewarded by [OpenStudio](https://www.openstudio.fr). +This page describes the security lifecycle of the project: how vulnerabilities are +reported, how fixes are developed and released, and how you are informed. + +The canonical disclosure policy — contact, scope, response targets and supported +versions — lives in the repository: +[SECURITY.md](https://github.com/thelia/thelia/blob/main/SECURITY.md). + +## Reporting a vulnerability + +Never use a public issue, discussion or pull request for a security problem. Report it +privately: + +- Preferred: open a private report from the + [Security tab](https://github.com/thelia/thelia/security/advisories/new) of the + affected repository ("Report a vulnerability"). +- Or email [contact@thelia.net](mailto:contact@thelia.net) with `[SECURITY]` in the + subject line. + +We acknowledge reports within 72 hours, triage within 7 days, and aim to release a fix +for a confirmed vulnerability within 90 days. Disclosure is coordinated with the +reporter: details stay confidential until a fixed release is available. + +## How a fix is developed + +1. The report is triaged privately by the maintainers: impact, affected components, + affected series. +2. The fix is developed in a private workspace (GitHub draft security advisory and its + temporary private fork), so nothing leaks before the release. +3. Like any change to Thelia, the fix is reviewed by a maintainer and must pass the + full test suites and static analysis before it is merged. +4. The fix is applied to every supported series (currently 3.0 and 2.6). + +## How a fix is released + +Security fixes ship as regular patch releases on each supported series — there is no +separate hotfix channel. Each release is: + +- tagged on GitHub with a GitHub release, which makes it available through + [Packagist](https://packagist.org/packages/thelia/thelia) immediately, +- accompanied by a CycloneDX SBOM (software bill of materials) generated from + `composer.lock` and attached to the GitHub release. + +## How you are informed + +- A **GitHub Security Advisory** is published on the affected repository once the fixed + release is available, with a CVE identifier requested through GitHub for + vulnerabilities affecting released versions. +- The **release notes** of the fixed version reference the advisory. + +To stay informed, watch the [thelia/thelia](https://github.com/thelia/thelia) +repository (Watch → Custom → Security alerts) and enable +[Dependabot alerts](https://docs.github.com/en/code-security/dependabot/dependabot-alerts/about-dependabot-alerts) +on your own project so GitHub warns you when a Thelia package you depend on is affected +by a published advisory. + +## Regulatory reporting + +Thelia is stewarded by OpenStudio as an open source software steward within the meaning of +article 24 of the EU Cyber Resilience Act (regulation 2024/2847), not as a manufacturer: +no CE marking and no conformity assessment apply. What does apply is a coordinated +vulnerability disclosure policy, cooperation with market surveillance authorities, and the +notification of actively exploited vulnerabilities and severe incidents. + +Two situations trigger a notification: a vulnerability under **active exploitation** +(exploitation observed in the wild, not a theoretical report), and a **severe incident** +affecting the development or distribution infrastructure, such as a compromise of the +GitHub organisations, of the release pipeline, or of the published packages. + +Notifications go to the [ENISA single reporting platform](https://www.enisa.europa.eu/), +which routes them to the coordinating CSIRT of the member state (CERT-FR for France), +on the article 14 timeline: + +| Deadline | Notification | +| --- | --- | +| 24 hours from awareness | Early warning | +| 72 hours from awareness | Vulnerability or incident notification, with severity, impact and mitigations | +| 14 days from the fix being available | Final report (one month for a severe incident) | + +The reporting obligations of the regulation apply from 11 September 2026. The full +process, including the internal checklist, lives in +[`docs/security/cra-incident-response.md`](https://github.com/thelia/thelia/blob/main/docs/security/cra-incident-response.md) +in the main repository. + +:::note +Notification duties sit with the steward. They never delay the fix: the remediation work +runs in parallel, and a release is not held back waiting for a notification. +::: + +## Keeping your installation secure + +- Stay on the most recent release of your series: security fixes only target the + latest 3.0 and 2.6 versions. +- Run [`composer audit`](https://getcomposer.org/doc/03-cli.md#audit) regularly (or in + your CI): it checks every installed package, Thelia included, against known security + advisories. +- Report anything suspicious privately, as described above. + +## See also + +- [Personal data](./personal-data.md): export, anonymization and retention of customer data. From a01f1a31b6bebdabaa1db842c96a136935bcd945 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Nozi=C3=A8re?= Date: Fri, 28 Aug 2026 14:09:35 +0200 Subject: [PATCH 2/3] docs: document the JWT refresh tokens --- docs/api/authentication.md | 70 ++++++++++++++++++- .../version-3.0/api/authentication.md | 70 ++++++++++++++++++- 2 files changed, 136 insertions(+), 4 deletions(-) diff --git a/docs/api/authentication.md b/docs/api/authentication.md index 7172c30f..5d351092 100644 --- a/docs/api/authentication.md +++ b/docs/api/authentication.md @@ -52,10 +52,16 @@ On successful authentication: ```json { - "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9..." + "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...", + "refresh_token": "9f2c...64 random bytes, hex encoded...", + "refresh_token_ttl": 2592000 } ``` +`token` is the JWT to send on every authenticated request. `refresh_token` is an opaque +value that buys a new one when it expires, and `refresh_token_ttl` is its own lifetime in +seconds. + ### Using the token Include the JWT token in the `Authorization` header for authenticated requests: @@ -80,6 +86,63 @@ The JWT token contains: The `type` field indicates whether the user is an Admin or a Customer. +## Refresh tokens + +A short-lived JWT keeps the damage of a leaked token small, but it forces the client to +send the credentials again every hour. The refresh token solves that: it is issued +alongside the JWT at login, and exchanged for a fresh pair when the JWT expires. + +### Refresh endpoints + +```http +POST /api/admin/token/refresh +POST /api/front/token/refresh +Content-Type: application/json + +{ + "refresh_token": "9f2c..." +} +``` + +The endpoints also accept a form-encoded body (`refresh_token=9f2c...`). The response has +the same shape as a login response: + +```json +{ + "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...", + "refresh_token": "1a7e...", + "refresh_token_ttl": 2592000 +} +``` + +### How it behaves + +- **Opaque, not a JWT.** The value is 64 random bytes, hex encoded. It carries no + information and cannot be decoded. +- **Single use.** Consuming a refresh token deletes it before the payload is returned, and + the response carries a new one. Replaying the same value fails. +- **Scoped.** A token issued on `/api/admin/login` only works on + `/api/admin/token/refresh`, and a token issued on `/api/front/login` only on + `/api/front/token/refresh`. Presenting one on the other endpoint returns `401`. +- **Stored in the cache pool.** Eviction invalidates the token and forces a new login. In + production, back the pool with a persistent adapter (Redis, filesystem) rather than an + in-memory one. + +| Response | Meaning | +| --- | --- | +| `400` | No `refresh_token` in the request body | +| `401` | Unknown, expired, already used, or wrong-scope token | +| `200` | New JWT and new refresh token | + +The lifetime is set by `JWT_REFRESH_TOKEN_TTL` (default `2592000`, i.e. 30 days). See +[JWT configuration](#jwt-configuration). + +:::tip +Store the refresh token where the access token is not: it is the credential that survives. +On a browser client, prefer a same-site cookie set by your own backend over +`localStorage`. +::: + ## Front routes (public) Public front routes (`/api/front/*`) do not require authentication: @@ -118,10 +181,12 @@ JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem JWT_PASSPHRASE=your-passphrase # Token lifetime in seconds (read by lexik_jwt_authentication.token_ttl) JWT_TOKEN_TTL=3600 +# Refresh token lifetime in seconds +JWT_REFRESH_TOKEN_TTL=2592000 ``` :::note -The bundle configuration (`config/packages/lexik_jwt_authentication.yaml`) maps these variables directly: `secret_key`, `public_key`, `pass_phrase` and `token_ttl: '%env(int:JWT_TOKEN_TTL)%'`. Tokens expire after `JWT_TOKEN_TTL` seconds (default `3600`, i.e. one hour). +The bundle configuration (`config/packages/lexik_jwt_authentication.yaml`) maps these variables directly: `secret_key`, `public_key`, `pass_phrase` and `token_ttl: '%env(int:JWT_TOKEN_TTL)%'`. Tokens expire after `JWT_TOKEN_TTL` seconds (default `3600`, i.e. one hour). `JWT_REFRESH_TOKEN_TTL` is read by Thelia itself, not by the bundle, and drives the [refresh tokens](#refresh-tokens). ::: ## CORS configuration @@ -182,6 +247,7 @@ Or for invalid/expired tokens: 2. Configure a token lifetime suited to your use case. 3. Keep the JWT private keys out of version control and restrict access to them. 4. Validate tokens on the server. Never trust client-side validation. +5. Treat the refresh token as the credential that survives: store it apart from the access token, and discard it on logout. ## OpenAPI documentation diff --git a/versioned_docs/version-3.0/api/authentication.md b/versioned_docs/version-3.0/api/authentication.md index 7172c30f..5d351092 100644 --- a/versioned_docs/version-3.0/api/authentication.md +++ b/versioned_docs/version-3.0/api/authentication.md @@ -52,10 +52,16 @@ On successful authentication: ```json { - "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9..." + "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...", + "refresh_token": "9f2c...64 random bytes, hex encoded...", + "refresh_token_ttl": 2592000 } ``` +`token` is the JWT to send on every authenticated request. `refresh_token` is an opaque +value that buys a new one when it expires, and `refresh_token_ttl` is its own lifetime in +seconds. + ### Using the token Include the JWT token in the `Authorization` header for authenticated requests: @@ -80,6 +86,63 @@ The JWT token contains: The `type` field indicates whether the user is an Admin or a Customer. +## Refresh tokens + +A short-lived JWT keeps the damage of a leaked token small, but it forces the client to +send the credentials again every hour. The refresh token solves that: it is issued +alongside the JWT at login, and exchanged for a fresh pair when the JWT expires. + +### Refresh endpoints + +```http +POST /api/admin/token/refresh +POST /api/front/token/refresh +Content-Type: application/json + +{ + "refresh_token": "9f2c..." +} +``` + +The endpoints also accept a form-encoded body (`refresh_token=9f2c...`). The response has +the same shape as a login response: + +```json +{ + "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...", + "refresh_token": "1a7e...", + "refresh_token_ttl": 2592000 +} +``` + +### How it behaves + +- **Opaque, not a JWT.** The value is 64 random bytes, hex encoded. It carries no + information and cannot be decoded. +- **Single use.** Consuming a refresh token deletes it before the payload is returned, and + the response carries a new one. Replaying the same value fails. +- **Scoped.** A token issued on `/api/admin/login` only works on + `/api/admin/token/refresh`, and a token issued on `/api/front/login` only on + `/api/front/token/refresh`. Presenting one on the other endpoint returns `401`. +- **Stored in the cache pool.** Eviction invalidates the token and forces a new login. In + production, back the pool with a persistent adapter (Redis, filesystem) rather than an + in-memory one. + +| Response | Meaning | +| --- | --- | +| `400` | No `refresh_token` in the request body | +| `401` | Unknown, expired, already used, or wrong-scope token | +| `200` | New JWT and new refresh token | + +The lifetime is set by `JWT_REFRESH_TOKEN_TTL` (default `2592000`, i.e. 30 days). See +[JWT configuration](#jwt-configuration). + +:::tip +Store the refresh token where the access token is not: it is the credential that survives. +On a browser client, prefer a same-site cookie set by your own backend over +`localStorage`. +::: + ## Front routes (public) Public front routes (`/api/front/*`) do not require authentication: @@ -118,10 +181,12 @@ JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem JWT_PASSPHRASE=your-passphrase # Token lifetime in seconds (read by lexik_jwt_authentication.token_ttl) JWT_TOKEN_TTL=3600 +# Refresh token lifetime in seconds +JWT_REFRESH_TOKEN_TTL=2592000 ``` :::note -The bundle configuration (`config/packages/lexik_jwt_authentication.yaml`) maps these variables directly: `secret_key`, `public_key`, `pass_phrase` and `token_ttl: '%env(int:JWT_TOKEN_TTL)%'`. Tokens expire after `JWT_TOKEN_TTL` seconds (default `3600`, i.e. one hour). +The bundle configuration (`config/packages/lexik_jwt_authentication.yaml`) maps these variables directly: `secret_key`, `public_key`, `pass_phrase` and `token_ttl: '%env(int:JWT_TOKEN_TTL)%'`. Tokens expire after `JWT_TOKEN_TTL` seconds (default `3600`, i.e. one hour). `JWT_REFRESH_TOKEN_TTL` is read by Thelia itself, not by the bundle, and drives the [refresh tokens](#refresh-tokens). ::: ## CORS configuration @@ -182,6 +247,7 @@ Or for invalid/expired tokens: 2. Configure a token lifetime suited to your use case. 3. Keep the JWT private keys out of version control and restrict access to them. 4. Validate tokens on the server. Never trust client-side validation. +5. Treat the refresh token as the credential that survives: store it apart from the access token, and discard it on logout. ## OpenAPI documentation From f015806c3188c5a937f3eed89a71d560df71ee28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Nozi=C3=A8re?= Date: Fri, 28 Aug 2026 14:09:35 +0200 Subject: [PATCH 3/3] docs: describe the regulatory reporting process --- docs/security/security-policy.md | 39 +++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/docs/security/security-policy.md b/docs/security/security-policy.md index 75875e86..15a6c974 100644 --- a/docs/security/security-policy.md +++ b/docs/security/security-policy.md @@ -61,6 +61,39 @@ repository (Watch → Custom → Security alerts) and enable on your own project so GitHub warns you when a Thelia package you depend on is affected by a published advisory. +## Regulatory reporting + +Thelia is stewarded by OpenStudio as an open source software steward within the meaning of +article 24 of the EU Cyber Resilience Act (regulation 2024/2847), not as a manufacturer: +no CE marking and no conformity assessment apply. What does apply is a coordinated +vulnerability disclosure policy, cooperation with market surveillance authorities, and the +notification of actively exploited vulnerabilities and severe incidents. + +Two situations trigger a notification: a vulnerability under **active exploitation** +(exploitation observed in the wild, not a theoretical report), and a **severe incident** +affecting the development or distribution infrastructure, such as a compromise of the +GitHub organisations, of the release pipeline, or of the published packages. + +Notifications go to the [ENISA single reporting platform](https://www.enisa.europa.eu/), +which routes them to the coordinating CSIRT of the member state (CERT-FR for France), +on the article 14 timeline: + +| Deadline | Notification | +| --- | --- | +| 24 hours from awareness | Early warning | +| 72 hours from awareness | Vulnerability or incident notification, with severity, impact and mitigations | +| 14 days from the fix being available | Final report (one month for a severe incident) | + +The reporting obligations of the regulation apply from 11 September 2026. The full +process, including the internal checklist, lives in +[`docs/security/cra-incident-response.md`](https://github.com/thelia/thelia/blob/main/docs/security/cra-incident-response.md) +in the main repository. + +:::note +Notification duties sit with the steward. They never delay the fix: the remediation work +runs in parallel, and a release is not held back waiting for a notification. +::: + ## Keeping your installation secure - Stay on the most recent release of your series: security fixes only target the @@ -68,4 +101,8 @@ by a published advisory. - Run [`composer audit`](https://getcomposer.org/doc/03-cli.md#audit) regularly (or in your CI): it checks every installed package, Thelia included, against known security advisories. -- Report anything suspicious privately — see above. +- Report anything suspicious privately, as described above. + +## See also + +- [Personal data](./personal-data.md): export, anonymization and retention of customer data.