diff --git a/extensions/gdpr/README.md b/extensions/gdpr/README.md index ca3d0e6f56..eb5ddee02e 100644 --- a/extensions/gdpr/README.md +++ b/extensions/gdpr/README.md @@ -27,7 +27,7 @@ From here, users may self-service export their data from the forum, or start an If your forum runs multiple queues, ie `low` and `high`, you may specify which queue jobs for this extension are run on in your skeleton's `extend.php` file: ```php -Blomstra\Gdpr\Jobs\GdprJob::$onQueue = 'low'; +Flarum\Gdpr\Jobs\GdprJob::$onQueue = 'low'; return [ ... your current extenders, @@ -40,12 +40,12 @@ You can easily register a new Data type, remove an existing Data type, or exclud #### Registering a new Data Type: -Your data type class should implement the `Blomstra\Gdpr\Contracts\DataType`: +Your data type class should implement the `Flarum\Gdpr\Contracts\DataType`: ```php whenExtensionEnabled('flarum-gdpr', fn () => [ (new UserData()) - ->removeUserColumn('column_name') // For a single column - ->removeUserColumns(['column1', 'column2']), // For multiple columns + ->removeUserColumns(['column1', 'column2']), ... other conditional extenders as required ... ]), ]; ``` + +#### PII fields and anonymized contexts + +##### What is an "anonymized context"? + +Some extensions need to share Flarum data with external systems — for example, publishing events to a message broker, syncing to a search index, or sending webhooks. In these scenarios there are typically two audiences: + +- **Full-data consumers** — internal systems that are authorised to process PII (e.g. a private analytics pipeline). +- **Anonymized consumers** — systems where PII must not appear (e.g. a public event stream, a third-party integration, or any consumer that doesn't need identifying information). + +An "anonymized context" is any such output where PII keys must be redacted before the data leaves the application. For example, [glowingblue/rabbit-dispatcher](https://github.com/glowingblue/rabbit-dispatcher) publishes Flarum events to RabbitMQ on two exchanges simultaneously: one with full payloads, and one with all PII keys replaced by `[redacted]`. The PII key list comes from `flarum/gdpr` so that every registered extension's sensitive fields are automatically covered. + +The GDPR admin page ("User Table Data" section) shows which fields are currently registered as PII, giving admins visibility into what will be redacted. + +##### Declaring PII fields on your data type + +If your extension stores personally identifiable information, declare which keys are PII by overriding `piiFields()` on your data type class. This is the preferred approach — the declaration lives alongside your `anonymize()` logic, and the keys are automatically included in the PII registry as soon as your type is registered. + +```php +use Flarum\Gdpr\Data\Type; + +class MyData extends Type +{ + public static function piiFields(): array + { + return ['custom_field', 'another_pii_field']; + } + + // ... export(), anonymize(), delete() ... +} +``` + +##### Declaring PII fields without a data type + +If your extension stores PII in a field that doesn't belong to any registered data type (e.g. a column on a model you don't export via GDPR), register the keys via the `UserData` extender instead: + +```php +use Flarum\Gdpr\Extend\UserData; + +return [ + (new Extend\Conditional()) + ->whenExtensionEnabled('flarum-gdpr', fn () => [ + (new UserData()) + ->addPiiKeysForSerialization(['custom_field', 'another_pii_field']), + ]), +]; +``` + +##### Building an anonymized context (consuming the PII list) + +If you are building an extension that serializes Flarum data for an external system and want to support PII redaction, resolve the PII key list from `DataProcessor` at runtime. Always check whether `flarum-gdpr` is enabled first and provide your own fallback for when it is not: + +```php +use Flarum\Extension\ExtensionManager; +use Flarum\Gdpr\DataProcessor; + +$extensions = resolve(ExtensionManager::class); + +if ($extensions->isEnabled('flarum-gdpr')) { + $piiKeys = resolve(DataProcessor::class)->getPiiKeysForSerialization(); +} else { + // Fallback covering common fields — used when flarum/gdpr is not installed. + $piiKeys = ['email', 'username', 'ip_address', 'last_ip_address']; +} + +// Recursively redact PII from your serialized payload before sending externally. +$anonymizedPayload = redactKeys($payload, $piiKeys); +``` + +`getPiiKeysForSerialization()` aggregates fields declared by all registered data types (via `piiFields()`) plus any extras registered via `addPiiKeysForSerialization()`. This means every enabled extension that participates in the GDPR registry contributes its PII fields automatically — your consumer code doesn't need to know about them individually. + ### Flarum extensions These are the known extensions which offer GDPR data integration with this extension. Don't see a required extension listed? Contact the author to request it diff --git a/extensions/gdpr/js/src/admin/components/GdprPage.tsx b/extensions/gdpr/js/src/admin/components/GdprPage.tsx index dadbd28ef1..53b10aacfd 100644 --- a/extensions/gdpr/js/src/admin/components/GdprPage.tsx +++ b/extensions/gdpr/js/src/admin/components/GdprPage.tsx @@ -8,14 +8,25 @@ import Tooltip from 'flarum/common/components/Tooltip'; import ExtensionLink from './ExtensionLink'; import LinkButton from 'flarum/common/components/LinkButton'; import Form from 'flarum/common/components/Form'; +import Icon from 'flarum/common/components/Icon'; + +type ColumnMeta = { type: string; length: number | null; default: string | null; nullable: boolean }; +type UserColumnsData = { + allColumns: Record; + removableColumns: Record; + piiKeys: string[]; + piiKeyExtensions: Record; +}; export default class GdprPage extends AdminPage { gdprDataTypes: DataType[] = []; + userColumnsData: UserColumnsData | null = null; oninit(vnode: Mithril.Vnode) { super.oninit(vnode); this.loadGdprDataTypes(); + this.loadUserColumnsData(); } headerInfo(): AdminHeaderAttrs { @@ -36,6 +47,18 @@ export default class GdprPage exten }); } + loadUserColumnsData() { + app + .request<{ data: UserColumnsData }>({ + method: 'GET', + url: app.forum.attribute('apiUrl') + '/gdpr-datatypes/user-columns', + }) + .then((response) => { + this.userColumnsData = response.data; + m.redraw(); + }); + } + content(): Mithril.Children { if (this.loading) { return ; @@ -61,7 +84,7 @@ export default class GdprPage exten

{app.translator.trans('flarum-gdpr.admin.gdpr_page.user_table_data.help_text')}

-
{app.translator.trans('flarum-gdpr.admin.gdpr_page.user_table_data.not_yet_implemented')}
+ {this.userColumnsData ? this.userColumnTable(this.userColumnsData) : }
@@ -99,4 +122,61 @@ export default class GdprPage exten ); } + + userColumnTable({ allColumns, removableColumns, piiKeys, piiKeyExtensions }: UserColumnsData): Mithril.Children { + const t = (key: string) => app.translator.trans(`flarum-gdpr.admin.gdpr_page.user_table_data.${key}`); + + return ( +
+
+
{t('column')}
+
{t('type')}
+
{t('nullable')}
+
{t('pii')}
+
{t('redacted_on_export')}
+
{t('extension')}
+
+ + {Object.entries(allColumns).map(([column, meta]) => { + const isPii = piiKeys.includes(column); + const redactedByExtension = column in removableColumns ? removableColumns[column] : undefined; + const isRedacted = redactedByExtension !== undefined; + const extensionId = redactedByExtension ?? (column in piiKeyExtensions ? piiKeyExtensions[column] : null); + + return ( +
+
+ {column} +
+
{meta.type}
+
{meta.nullable ? t('yes') : t('no')}
+
+ {isPii ? ( + + + {t('yes')} + + + ) : ( + {t('no')} + )} +
+
+ {isRedacted ? ( + + {t('yes')} + + ) : ( + {t('no')} + )} +
+
+ +
+
+ ); + })} +
+ ); + } } diff --git a/extensions/gdpr/js/src/admin/extend.tsx b/extensions/gdpr/js/src/admin/extend.tsx index ae912b03e2..bcdf13cad7 100644 --- a/extensions/gdpr/js/src/admin/extend.tsx +++ b/extensions/gdpr/js/src/admin/extend.tsx @@ -15,19 +15,15 @@ export default [ .add('gdpr-datatypes', DataType), new Extend.Admin() - .setting(() => ({ - setting: 'flarum-gdpr.gdpr_page_link', - type: 'custom', - component: () => ( -
- -

{app.translator.trans('flarum-gdpr.admin.settings.gdpr_page.help_text')}

- - {app.translator.trans('flarum-gdpr.admin.nav.gdpr_button')} - -
- ), - })) + .customSetting(() => ( +
+ +

{app.translator.trans('flarum-gdpr.admin.settings.gdpr_page.help_text')}

+ + {app.translator.trans('flarum-gdpr.admin.nav.gdpr_button')} + +
+ )) .setting(() => ({ setting: 'flarum-gdpr.allow-anonymization', label: app.translator.trans('flarum-gdpr.admin.settings.allow_anonymization'), @@ -52,7 +48,7 @@ export default [ })) .setting(() => ({ setting: 'flarum-gdpr.default-anonymous-username', - type: 'string', + type: 'text', label: app.translator.trans('flarum-gdpr.admin.settings.default_anonymous_username'), help: app.translator.trans('flarum-gdpr.admin.settings.default_anonymous_username_help'), })) diff --git a/extensions/gdpr/js/src/forum/components/ExportAvailableNotification.ts b/extensions/gdpr/js/src/forum/components/ExportAvailableNotification.ts index 23e0889851..e10c421ce2 100644 --- a/extensions/gdpr/js/src/forum/components/ExportAvailableNotification.ts +++ b/extensions/gdpr/js/src/forum/components/ExportAvailableNotification.ts @@ -8,13 +8,17 @@ export default class ExportAvailableNotification extends Notification { return 'fas fa-file-export'; } - href() { + exportUrl() { const exportModel = this.attrs.notification.subject() as Export; - - // Building the full url scheme so that Mithril treats this as an external link, so the download will work correctly. return app.forum.attribute('baseUrl') + `/gdpr/export/${exportModel.file()}`; } + href() { + // Return a non-navigating href; the download is opened via window.open() in + // markAsRead() so the mark-as-read XHR is not aborted by a page navigation. + return '#'; + } + content() { const notification = this.attrs.notification; return app.translator.trans('flarum-gdpr.forum.notification.export-ready', { @@ -25,4 +29,16 @@ export default class ExportAvailableNotification extends Notification { excerpt() { return null; } + + markAsRead() { + // Open the download in a new tab so the current page is not navigated away + // from, keeping the mark-as-read XHR alive. + window.open(this.exportUrl(), '_blank'); + + if (this.attrs.notification.isRead()) return; + + app.session.user?.pushAttributes({ unreadNotificationCount: (app.session.user.unreadNotificationCount() ?? 1) - 1 }); + + this.attrs.notification.save({ isRead: true }); + } } diff --git a/extensions/gdpr/resources/less/admin.less b/extensions/gdpr/resources/less/admin.less index 102e7dbe9d..f0318ae529 100644 --- a/extensions/gdpr/resources/less/admin.less +++ b/extensions/gdpr/resources/less/admin.less @@ -38,14 +38,28 @@ background-color: var(--control-bg); color: var(--control-color); border-bottom: 1px solid var(--muted-color); - + > div { background-color: var(--control-bg); } } } + + .GdprGrid--userColumns { + .GdprGrid-row { + grid-template-columns: 2fr 1fr 1fr 1fr 1fr 1fr; + } + } + + .GdprPiiBadge { + display: inline-flex; + align-items: center; + gap: 4px; + color: @primary-color; + font-weight: 600; + } } - + [data-theme^=light] { --body-bg-darken-3: darken(@body-bg-light, 3%); diff --git a/extensions/gdpr/resources/locale/en.yml b/extensions/gdpr/resources/locale/en.yml index 7002fd1048..94b9428ab1 100644 --- a/extensions/gdpr/resources/locale/en.yml +++ b/extensions/gdpr/resources/locale/en.yml @@ -18,11 +18,20 @@ flarum-gdpr: help_text: Looking for GDPR settings? They're found on the extension page. extension_settings_button: GDPR Settings user_table_data: - not_yet_implemented: Not yet implemented title: User Table Data help_text: | On the most part, any columns added to the user table will be handled automatically, both for exporting data and for erasure. However, there are some special cases, which are listed below. + column: Column + type: Type + nullable: Nullable + pii: PII + redacted_on_export: Redacted on export + extension: Extension + yes: Yes + no: No + pii_tooltip: This column is considered personally identifiable information and will be redacted in anonymized contexts (e.g. anonymized event payloads). + redacted_on_export_tooltip: "This column's value is blanked (set to null) when generating a user data export. The column still appears in the export with a null value." nav: gdpr_button: GDPR Integrations gdpr_title: => flarum-gdpr.admin.gdpr_page.description @@ -62,6 +71,7 @@ flarum-gdpr: export_description: Exports all discussions the user has started. Data restricted to title and creation date. forum: export_description: Exports the forum title, url, username, email and the current date. + default_user_action: "No action, handled by default user table data handling." no_action: No action taken. posts: anonymize_description: Removes the IP address from all posts the user has made. diff --git a/extensions/gdpr/src/Api/Resource/DataTypeResource.php b/extensions/gdpr/src/Api/Resource/DataTypeResource.php index 0ac8c57b26..66eacba317 100644 --- a/extensions/gdpr/src/Api/Resource/DataTypeResource.php +++ b/extensions/gdpr/src/Api/Resource/DataTypeResource.php @@ -46,12 +46,16 @@ public function endpoints(): array ->route('GET', '/user-columns') ->admin() ->action(function () { - $removableColumns = $this->processor->removableUserColumns(); $allColumns = $this->processor->allUserColumns(); - return compact('removableColumns', 'allColumns'); + return [ + 'removableColumns' => $this->processor->removableUserColumnsWithExtensions(), + 'allColumns' => $allColumns, + 'piiKeys' => $this->processor->getPiiKeysForSerialization(), + 'piiKeyExtensions' => $this->processor->getPiiKeysWithExtensions(), + ]; }) - ->response(function (array $data) { + ->response(function (OriginalContext $context, array $data) { return new JsonResponse(compact('data')); }), ]; diff --git a/extensions/gdpr/src/Contracts/DataType.php b/extensions/gdpr/src/Contracts/DataType.php index a628f3bda2..60cf11c3d0 100644 --- a/extensions/gdpr/src/Contracts/DataType.php +++ b/extensions/gdpr/src/Contracts/DataType.php @@ -58,4 +58,13 @@ public static function deleteDescription(): string; * @return void */ public function delete(): void; + + /** + * Keys within this data type that contain PII. + * Used to redact sensitive fields when serializing for non-PII contexts + * (e.g. anonymized event payloads to a message broker). + * + * @return string[] + */ + public static function piiFields(): array; } diff --git a/extensions/gdpr/src/Data/Posts.php b/extensions/gdpr/src/Data/Posts.php index 616dcee663..d2285dc458 100644 --- a/extensions/gdpr/src/Data/Posts.php +++ b/extensions/gdpr/src/Data/Posts.php @@ -14,6 +14,11 @@ class Posts extends Type { + public static function piiFields(): array + { + return ['ip_address']; + } + public function export(): ?array { $exportData = []; diff --git a/extensions/gdpr/src/Data/Tokens.php b/extensions/gdpr/src/Data/Tokens.php index 15e3d0c8ff..bacd1e171b 100644 --- a/extensions/gdpr/src/Data/Tokens.php +++ b/extensions/gdpr/src/Data/Tokens.php @@ -27,6 +27,11 @@ class Tokens extends Type PasswordToken::class, ]; + public static function piiFields(): array + { + return ['last_ip_address']; + } + public function export(): ?array { $exportData = []; diff --git a/extensions/gdpr/src/Data/Type.php b/extensions/gdpr/src/Data/Type.php index e4cfeed5f1..13e955666c 100644 --- a/extensions/gdpr/src/Data/Type.php +++ b/extensions/gdpr/src/Data/Type.php @@ -57,6 +57,11 @@ public static function dataType(): string return Str::afterLast(static::class, '\\'); } + public static function piiFields(): array + { + return []; + } + public function getDisk(?string $name): Filesystem { return $this->factory->disk($name); diff --git a/extensions/gdpr/src/Data/User.php b/extensions/gdpr/src/Data/User.php index e4c53ea8e4..a7a2db3b43 100644 --- a/extensions/gdpr/src/Data/User.php +++ b/extensions/gdpr/src/Data/User.php @@ -15,6 +15,11 @@ class User extends Type { + public static function piiFields(): array + { + return ['email', 'username', 'last_seen_at', 'joined_at', 'preferences', 'nickname', 'suspend_reason', 'suspend_message']; + } + public function export(): ?array { $remove = ['id', 'password', 'groups', 'anonymized']; diff --git a/extensions/gdpr/src/DataProcessor.php b/extensions/gdpr/src/DataProcessor.php index 25de00a5a1..8de9fb9371 100644 --- a/extensions/gdpr/src/DataProcessor.php +++ b/extensions/gdpr/src/DataProcessor.php @@ -33,7 +33,7 @@ final class DataProcessor ]; /** - * @var string[] List of user columns to be removed. + * @var array Map of column name => extension ID (or null for core). */ private static array $removeUserColumns = []; @@ -42,6 +42,15 @@ final class DataProcessor */ private static $columnActions = []; + /** + * Additional PII keys for serialization anonymization, beyond those declared by registered + * data types via {@see DataType::piiFields()}. Use this only for PII fields that don't + * belong to any registered data type. + * + * @var array Map of key name => extension ID (or null for core). + */ + private static array $extraPiiKeysForSerialization = []; + /** * Add a data type to the list. * @@ -81,11 +90,22 @@ public static function setTypes(array $types): void /** * Add columns to the list of user columns to be removed. * - * @param string[] $columns List of column names. + * @param string[] $columns List of column names. + * @param string|null $extensionId The ID of the extension registering the columns. + */ + public static function removeUserColumns(array $columns, ?string $extensionId = null): void + { + foreach ($columns as $column) { + self::$removeUserColumns[$column] = $extensionId; + } + } + + /** + * Reset the removable user columns list. Intended for use in tests. */ - public static function removeUserColumns(array $columns): void + public static function resetRemovableUserColumns(): void { - self::$removeUserColumns = array_merge(self::$removeUserColumns, $columns); + self::$removeUserColumns = []; } /** @@ -99,11 +119,21 @@ public function types(): array } /** - * Retrieve the list of user columns to be removed. + * Retrieve the list of user columns to be removed (column names only). * * @return string[] List of column names. */ public function removableUserColumns(): array + { + return array_keys(self::$removeUserColumns); + } + + /** + * Retrieve the full map of removable user columns with their registering extension IDs. + * + * @return array Map of column name => extension ID (or null for core). + */ + public function removableUserColumnsWithExtensions(): array { return self::$removeUserColumns; } @@ -144,4 +174,77 @@ public function getColumnActions(): array { return self::$columnActions; } + + /** + * Reset the extra PII keys list. Intended for use in tests. + */ + public static function resetExtraPiiKeysForSerialization(): void + { + self::$extraPiiKeysForSerialization = []; + } + + /** + * Register additional PII keys for serialization anonymization that are not declared + * by any registered data type. Prefer declaring PII fields on the data type itself + * via {@see DataType::piiFields()} wherever possible. + * + * @param string[] $keys + * @param string|null $extensionId The ID of the extension registering the keys. + */ + public static function addPiiKeysForSerialization(array $keys, ?string $extensionId = null): void + { + foreach ($keys as $key) { + if (! array_key_exists($key, self::$extraPiiKeysForSerialization)) { + self::$extraPiiKeysForSerialization[$key] = $extensionId; + } + } + } + + /** + * Get a map of every PII key to the extension ID that declared it. + * Keys declared by built-in types or core are mapped to null. + * When two sources declare the same key, the first wins (type-declared > extra). + * + * @return array Map of key name => extension ID (or null for core). + */ + public function getPiiKeysWithExtensions(): array + { + $result = []; + + foreach (self::$types as $typeClass => $extensionId) { + foreach ($typeClass::piiFields() as $field) { + if (! array_key_exists($field, $result)) { + $result[$field] = $extensionId; + } + } + } + + foreach (self::$extraPiiKeysForSerialization as $field => $extensionId) { + if (! array_key_exists($field, $result)) { + $result[$field] = $extensionId; + } + } + + return $result; + } + + /** + * Get the full list of PII keys for serialization anonymization. + * Aggregates fields declared by all registered data types, plus any extras + * added via {@see addPiiKeysForSerialization()}. + * + * @return string[] + */ + public function getPiiKeysForSerialization(): array + { + /** @var string[] $fromTypes */ + $fromTypes = array_merge( + ...array_map(fn (string $type) => $type::piiFields(), array_keys(self::$types)) + ); + + /** @var string[] $merged */ + $merged = array_unique(array_merge($fromTypes, array_keys(self::$extraPiiKeysForSerialization))); + + return array_values($merged); + } } diff --git a/extensions/gdpr/src/Exporter.php b/extensions/gdpr/src/Exporter.php index 3bf0535bd2..795b5f0b91 100644 --- a/extensions/gdpr/src/Exporter.php +++ b/extensions/gdpr/src/Exporter.php @@ -57,6 +57,10 @@ public function export(User $user, User $actor): Export $data = $segment->export(); + if ($data === null) { + continue; + } + // Check if the array is an indexed array of associative arrays if (is_array($data) && array_values($data) === $data) { // Handling list of associative arrays diff --git a/extensions/gdpr/src/Extend/UserData.php b/extensions/gdpr/src/Extend/UserData.php index 95d50cb03f..92eeedb8f0 100644 --- a/extensions/gdpr/src/Extend/UserData.php +++ b/extensions/gdpr/src/Extend/UserData.php @@ -19,6 +19,7 @@ class UserData implements ExtenderInterface protected array $types = []; protected array $removeTypes = []; protected array $removeUserColumns = []; + protected array $piiKeysForSerialization = []; public function extend(Container $container, ?Extension $extension = null): void { @@ -30,7 +31,8 @@ public function extend(Container $container, ?Extension $extension = null): void DataProcessor::removeType($type); } - DataProcessor::removeUserColumns($this->removeUserColumns); + DataProcessor::removeUserColumns($this->removeUserColumns, $extension?->getId()); + DataProcessor::addPiiKeysForSerialization($this->piiKeysForSerialization, $extension?->getId()); } /** @@ -77,4 +79,22 @@ public function removeUserColumns($columns): self return $this; } + + /** + * Register additional PII keys for serialization anonymization that are not covered by + * any registered data type. Prefer implementing {@see \Flarum\Gdpr\Contracts\DataType::piiFields()} + * on your data type class instead — that keeps the PII declaration co-located with the + * anonymization logic. Use this method only for PII fields that don't belong to any type. + * + * @param string|string[] $keys + * + * @return self + */ + public function addPiiKeysForSerialization($keys): self + { + $keys = (array) $keys; + $this->piiKeysForSerialization = array_merge($this->piiKeysForSerialization, $keys); + + return $this; + } } diff --git a/extensions/gdpr/tests/integration/api/ExtenderTest.php b/extensions/gdpr/tests/integration/api/ExtenderTest.php index 5bbdd7213b..0807224b7c 100644 --- a/extensions/gdpr/tests/integration/api/ExtenderTest.php +++ b/extensions/gdpr/tests/integration/api/ExtenderTest.php @@ -10,6 +10,7 @@ namespace Flarum\Gdpr\Tests\integration\api; use Flarum\Gdpr\Data\Forum; +use Flarum\Gdpr\Data\Type; use Flarum\Gdpr\DataProcessor; use Flarum\Gdpr\Extend\UserData; use Flarum\Testing\integration\TestCase; @@ -94,6 +95,66 @@ public function custom_user_columns_can_be_added() $this->assertContains('another_column', $columns); } + #[Test] + public function custom_pii_key_can_be_registered_via_extender() + { + $this->extend( + (new UserData()) + ->addPiiKeysForSerialization('custom_pii_field') + ); + + $this->app(); + + $keys = $this->getDataProcessor()->getPiiKeysForSerialization(); + + $this->assertContains('custom_pii_field', $keys); + } + + #[Test] + public function multiple_custom_pii_keys_can_be_registered_via_extender() + { + $this->extend( + (new UserData()) + ->addPiiKeysForSerialization(['field_a', 'field_b']) + ); + + $this->app(); + + $keys = $this->getDataProcessor()->getPiiKeysForSerialization(); + + $this->assertContains('field_a', $keys); + $this->assertContains('field_b', $keys); + } + + #[Test] + public function built_in_pii_fields_are_present_without_any_extender() + { + $this->app(); + + $keys = $this->getDataProcessor()->getPiiKeysForSerialization(); + + $this->assertContains('email', $keys); + $this->assertContains('username', $keys); + $this->assertContains('ip_address', $keys); + $this->assertContains('last_ip_address', $keys); + } + + #[Test] + public function pii_fields_from_custom_data_type_are_included() + { + $this->extend( + (new UserData()) + ->addType(DataTypeWithPii::class) + ); + + $this->app(); + + $keys = $this->getDataProcessor()->getPiiKeysForSerialization(); + + $this->assertContains('bio', $keys); + $this->assertContains('location', $keys); + } + protected function getDataProcessor(): DataProcessor { return $this->app()->getContainer()->make(DataProcessor::class); @@ -107,3 +168,39 @@ public static function dataType(): string return 'my-new-data-type'; } } + +class DataTypeWithPii extends Type +{ + public static function piiFields(): array + { + return ['bio', 'location']; + } + + public static function exportDescription(): string + { + return ''; + } + + public function export(): ?array + { + return null; + } + + public static function anonymizeDescription(): string + { + return ''; + } + + public function anonymize(): void + { + } + + public static function deleteDescription(): string + { + return ''; + } + + public function delete(): void + { + } +} diff --git a/extensions/gdpr/tests/integration/api/ListUserColumnsDataControllerTest.php b/extensions/gdpr/tests/integration/api/ListUserColumnsDataControllerTest.php new file mode 100644 index 0000000000..7bc83510d0 --- /dev/null +++ b/extensions/gdpr/tests/integration/api/ListUserColumnsDataControllerTest.php @@ -0,0 +1,132 @@ +prepareDatabase([ + 'users' => [ + $this->normalUser(), + ], + ]); + + $this->extension('flarum-gdpr'); + } + + #[Test] + public function non_admin_cannot_access_user_columns() + { + $response = $this->send( + $this->request('GET', '/api/gdpr-datatypes/user-columns', ['authenticatedAs' => 2]) + ); + + $this->assertEquals(403, $response->getStatusCode()); + } + + #[Test] + public function admin_can_access_user_columns() + { + $response = $this->send( + $this->request('GET', '/api/gdpr-datatypes/user-columns', ['authenticatedAs' => 1]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $body = json_decode($response->getBody()->getContents(), true); + + $this->assertArrayHasKey('data', $body); + $this->assertArrayHasKey('allColumns', $body['data']); + $this->assertArrayHasKey('removableColumns', $body['data']); + $this->assertArrayHasKey('piiKeys', $body['data']); + } + + #[Test] + public function response_includes_built_in_pii_keys() + { + $response = $this->send( + $this->request('GET', '/api/gdpr-datatypes/user-columns', ['authenticatedAs' => 1]) + ); + + $body = json_decode($response->getBody()->getContents(), true); + $piiKeys = $body['data']['piiKeys']; + + $this->assertContains('email', $piiKeys); + $this->assertContains('username', $piiKeys); + $this->assertContains('last_seen_at', $piiKeys); + $this->assertContains('joined_at', $piiKeys); + $this->assertContains('preferences', $piiKeys); + } + + #[Test] + public function response_includes_extra_pii_keys_registered_via_extender() + { + $this->extend( + (new UserData()) + ->addPiiKeysForSerialization('custom_pii_field') + ); + + $response = $this->send( + $this->request('GET', '/api/gdpr-datatypes/user-columns', ['authenticatedAs' => 1]) + ); + + $body = json_decode($response->getBody()->getContents(), true); + $piiKeys = $body['data']['piiKeys']; + + $this->assertContains('custom_pii_field', $piiKeys); + $this->assertContains('email', $piiKeys); // built-in keys still present + } + + #[Test] + public function response_includes_removable_columns_registered_via_extender() + { + $this->extend( + (new UserData()) + ->removeUserColumns(['my_custom_column']) + ); + + $response = $this->send( + $this->request('GET', '/api/gdpr-datatypes/user-columns', ['authenticatedAs' => 1]) + ); + + $body = json_decode($response->getBody()->getContents(), true); + + $this->assertArrayHasKey('my_custom_column', $body['data']['removableColumns']); + } + + #[Test] + public function all_columns_includes_standard_user_table_columns() + { + $response = $this->send( + $this->request('GET', '/api/gdpr-datatypes/user-columns', ['authenticatedAs' => 1]) + ); + + $body = json_decode($response->getBody()->getContents(), true); + $allColumns = $body['data']['allColumns']; + + $this->assertArrayHasKey('id', $allColumns); + $this->assertArrayHasKey('username', $allColumns); + $this->assertArrayHasKey('email', $allColumns); + + // Each column entry has the expected metadata shape + $this->assertArrayHasKey('type', $allColumns['id']); + $this->assertArrayHasKey('nullable', $allColumns['id']); + } +} diff --git a/extensions/gdpr/tests/unit/DataProcessorTest.php b/extensions/gdpr/tests/unit/DataProcessorTest.php index 49e819cf33..ec635e624f 100644 --- a/extensions/gdpr/tests/unit/DataProcessorTest.php +++ b/extensions/gdpr/tests/unit/DataProcessorTest.php @@ -20,7 +20,7 @@ protected function setUp(): void { parent::setUp(); - // Resetting the types and removeUserColumns properties before each test + // Resetting all static state before each test DataProcessor::setTypes([ Data\Forum::class => null, Data\Assets::class => null, @@ -29,7 +29,8 @@ protected function setUp(): void Data\Discussions::class => null, Data\User::class => null, ]); - DataProcessor::removeUserColumns([]); + DataProcessor::resetRemovableUserColumns(); + DataProcessor::resetExtraPiiKeysForSerialization(); } #[Test] @@ -102,4 +103,99 @@ public function user_class_is_always_the_last_entry() $types = $processor->types(); $this->assertEquals(Data\User::class, array_key_last($types)); } + + #[Test] + public function it_aggregates_pii_fields_from_registered_types() + { + $processor = new DataProcessor(); + + $keys = $processor->getPiiKeysForSerialization(); + + // Fields declared by built-in types + $this->assertContains('email', $keys); // Data\User + $this->assertContains('username', $keys); // Data\User + $this->assertContains('last_seen_at', $keys); // Data\User + $this->assertContains('joined_at', $keys); // Data\User + $this->assertContains('preferences', $keys); // Data\User + $this->assertContains('ip_address', $keys); // Data\Posts + $this->assertContains('last_ip_address', $keys); // Data\Tokens + } + + #[Test] + public function it_merges_extra_pii_keys_with_type_pii_fields() + { + $processor = new DataProcessor(); + + DataProcessor::addPiiKeysForSerialization(['custom_field']); + + $keys = $processor->getPiiKeysForSerialization(); + + $this->assertContains('custom_field', $keys); + $this->assertContains('email', $keys); // still includes type-sourced keys + } + + #[Test] + public function it_deduplicates_pii_keys() + { + $processor = new DataProcessor(); + + // 'email' is already declared by Data\User::piiFields() + DataProcessor::addPiiKeysForSerialization(['email', 'email', 'custom_field']); + + $keys = $processor->getPiiKeysForSerialization(); + + $this->assertCount(1, array_filter($keys, fn ($k) => $k === 'email')); + $this->assertCount(1, array_filter($keys, fn ($k) => $k === 'custom_field')); + } + + #[Test] + public function removing_a_type_removes_its_pii_fields() + { + $processor = new DataProcessor(); + + DataProcessor::removeType(Data\Posts::class); + + $keys = $processor->getPiiKeysForSerialization(); + + $this->assertNotContains('ip_address', $keys); + } + + #[Test] + public function types_with_no_pii_fields_contribute_nothing() + { + // Forum and Discussions declare no PII fields + DataProcessor::setTypes([ + Data\Forum::class => null, + Data\Discussions::class => null, + ]); + $processor = new DataProcessor(); + + $keys = $processor->getPiiKeysForSerialization(); + + $this->assertEmpty($keys); + } + + #[Test] + public function extra_pii_keys_are_included_even_when_no_types_declare_pii() + { + DataProcessor::setTypes([Data\Forum::class => null]); + DataProcessor::addPiiKeysForSerialization(['my_field']); + $processor = new DataProcessor(); + + $keys = $processor->getPiiKeysForSerialization(); + + $this->assertEquals(['my_field'], $keys); + } + + #[Test] + public function reset_clears_extra_pii_keys() + { + DataProcessor::addPiiKeysForSerialization(['my_field']); + DataProcessor::resetExtraPiiKeysForSerialization(); + + $processor = new DataProcessor(); + $keys = $processor->getPiiKeysForSerialization(); + + $this->assertNotContains('my_field', $keys); + } } diff --git a/extensions/gdpr/tests/unit/TypeTest.php b/extensions/gdpr/tests/unit/TypeTest.php index 271033ce75..c96892adca 100644 --- a/extensions/gdpr/tests/unit/TypeTest.php +++ b/extensions/gdpr/tests/unit/TypeTest.php @@ -10,7 +10,10 @@ namespace Flarum\Gdpr\tests\unit; use Flarum\Database\AbstractModel; +use Flarum\Gdpr\Data\Posts; +use Flarum\Gdpr\Data\Tokens; use Flarum\Gdpr\Data\Type; +use Flarum\Gdpr\Data\User as UserData; use Flarum\Gdpr\Models\ErasureRequest; use Flarum\Http\UrlGenerator; use Flarum\Settings\SettingsRepositoryInterface; @@ -142,6 +145,39 @@ public function it_returns_the_correct_table_columns() // Then $this->assertEquals($columns, $returnedColumns); } + + #[Test] + public function base_type_returns_empty_pii_fields() + { + $this->assertEquals([], TestableType::piiFields()); + } + + #[Test] + public function user_data_type_declares_expected_pii_fields() + { + $fields = UserData::piiFields(); + + $this->assertContains('email', $fields); + $this->assertContains('username', $fields); + $this->assertContains('last_seen_at', $fields); + $this->assertContains('joined_at', $fields); + $this->assertContains('preferences', $fields); + $this->assertContains('nickname', $fields); + $this->assertContains('suspend_reason', $fields); + $this->assertContains('suspend_message', $fields); + } + + #[Test] + public function posts_data_type_declares_expected_pii_fields() + { + $this->assertEquals(['ip_address'], Posts::piiFields()); + } + + #[Test] + public function tokens_data_type_declares_expected_pii_fields() + { + $this->assertEquals(['last_ip_address'], Tokens::piiFields()); + } } // TestableType class to expose protected method for testing