From c74d730b9d893d826e5c0b74bced7759c46c157d Mon Sep 17 00:00:00 2001 From: Jeremy Skinner Date: Thu, 8 Mar 2018 16:25:05 +0000 Subject: [PATCH 01/16] Initian implementation of anonymization/removal logic for GDPR. --- modules/gdpr_tasks/gdpr_tasks.services.yml | 3 + modules/gdpr_tasks/src/Anonymizer.php | 231 ++++++++++++++++++ .../src/Event/GetAnonymizersEvent.php | 37 +++ .../gdpr_tasks/src/Form/TaskActionsForm.php | 108 ++++++-- 4 files changed, 355 insertions(+), 24 deletions(-) create mode 100644 modules/gdpr_tasks/src/Anonymizer.php create mode 100644 modules/gdpr_tasks/src/Event/GetAnonymizersEvent.php diff --git a/modules/gdpr_tasks/gdpr_tasks.services.yml b/modules/gdpr_tasks/gdpr_tasks.services.yml index b956a2f..a0ecf55 100644 --- a/modules/gdpr_tasks/gdpr_tasks.services.yml +++ b/modules/gdpr_tasks/gdpr_tasks.services.yml @@ -2,3 +2,6 @@ services: gdpr_tasks.manager: class: Drupal\gdpr_tasks\TaskManager arguments: ['@entity_type.manager', '@current_user'] + gdpr_tasks.anonymizer: + class: Drupal\gdpr_tasks\Anonymizer + arguments: ['@gdpr_fields.collector', '@event_dispatcher', '@database'] \ No newline at end of file diff --git a/modules/gdpr_tasks/src/Anonymizer.php b/modules/gdpr_tasks/src/Anonymizer.php new file mode 100644 index 0000000..73244e8 --- /dev/null +++ b/modules/gdpr_tasks/src/Anonymizer.php @@ -0,0 +1,231 @@ +collector = $collector; + $this->dispatcher = $dispatcher; + $this->db = $db; + } + + /** + * Runs anonymization routines against a user. + */ + public function run(User $user) { + $errors = []; + $entities = []; + $successes = []; + $failures = []; + + $this->collector->getValueEntities($entities, 'user', $user); + + foreach ($entities as $entity_type => $bundles) { + foreach ($bundles as $bundle_entity) { + + $entity_success = TRUE; + + foreach ($this->getFieldsToProcess($bundle_entity) as $field_info) { + /** @var \Drupal\Core\Field\FieldItemListInterface $field */ + $field = $field_info['field']; + $mode = $field_info['mode']; + + $success = TRUE; + $msg = NULL; + + if ($mode == 'anonymise') { + list($success, $msg) = $this->anonymize($field); + } + elseif ($mode == 'remove') { + list($success, $msg) = $this->remove($field); + } + + if ($success === FALSE) { + // Could not anonymize/remove field. Record to errors list. + // Prevent entity from being saved. + $entity_success = FALSE; + $errors[] = $msg; + } + } + + if ($entity_success) { + $successes[] = $bundle_entity; + } + else { + $failures[] = $bundle_entity; + } + } + } + + if (count($failures) === 0) { + $tx = $this->db->startTransaction(); + + try { + foreach ($successes as $entity) { + $entity->save(); + } + } + catch (\Exception $e) { + $tx->rollBack(); + $errors[] = $e->getMessage(); + } + } + + // @todo this + // Now we've finished processing any defined fields. + // We must always process the following: + // - Anonymize the username + // - Anonymize the email + // - Remove the password + // - Remove all roles + // - Block the user + // - Store that they've been anonymized. + + return $errors; + } + + /** + * Removes the field value. + */ + private function remove(FieldItemListInterface $field) { + try { + $field->setValue(NULL); + return [TRUE, NULL]; + } + catch (ReadOnlyException $e) { + return [FALSE, $e->getMessage()]; + } + } + + /** + * Anonymizes a field. + * + * Uses the GetAnonymizersEvent to find an appropriate anonymization function. + */ + private function anonymize(FieldItemListInterface $field) { + // For string fields, generate a random string the same length. + $type = $field->getFieldDefinition()->getType(); + $event = new GetAnonymizersEvent(); + // Dispatch the event to allow other modules + // to register anonymization functions for particular entity types. + $this->dispatcher->dispatch(GetAnonymizersEvent::EVENT_NAME, $event); + + if (isset($event->anonymizers[$type]) && is_callable($event->anonymizers[$type])) { + try { + call_user_func($event->anonymizers[$type], $field); + return [TRUE, NULL]; + } + catch (\Exception $e) { + return [FALSE, $e->getMessage()]; + } + } + else { + return [ + FALSE, + "Could not anonymize field {$field->getName()}. Please consider changing this field from 'anonymize' to 'remove', or register a custom anonymizer function for the type {$type}.", + ]; + } + } + + /** + * Gets fields to anonymize/remove. + */ + private function getFieldsToProcess(EntityInterface $entity) { + $bundle_id = $entity->bundle(); + + // Get fields for entity. + $fields = []; + foreach ($entity as $field_id => $field) { + /** @var \Drupal\Core\Field\FieldItemListInterface $field */ + $field_definition = $field->getFieldDefinition(); + + $config = $field_definition->getConfig($bundle_id); + + if (!$config->getThirdPartySetting('gdpr_fields', 'gdpr_fields_enabled', FALSE)) { + continue; + } + + $rtf_value = $config->getThirdPartySetting('gdpr_fields', 'gdpr_fields_rtf', FALSE); + + if ($rtf_value && $rtf_value !== 'no') { + $fields[] = [ + 'entity_type' => $entity->getEntityTypeId(), + 'bundle' => $bundle_id, + 'field' => $field, + 'mode' => $rtf_value, + ]; + } + + } + + return $fields; + } + + /** + * Replaces string field value with a random value if the field is non-empty. + * + * @throws \Drupal\Core\TypedData\Exception\ReadOnlyException + */ + public static function anonymizeString(FieldItemListInterface $field) { + $value = $field->getString(); + $max_length = $field->getDataDefinition()->getSetting("max_length"); + + if (!empty($value)) { + // Generate a prefixed random string. + $value = "anon_" . self::generateRandomString(4); + // If the value is too long, tirm it. + if (isset($max_length) && strlen($value) > $max_length) { + $value = substr(0, $max_length); + } + + $field->setValue($value); + } + } + + /** + * Replaces date field value with a random value if the field is non-empty. + * + * @throws \Drupal\Core\TypedData\Exception\ReadOnlyException + */ + public static function anonymizeDate(FieldItemListInterface $field) { + if (isset($field->value)) { + $field->setValue(date('1000-01-01')); + } + } + + /** + * Generates a random string of a specified length. + */ + private static function generateRandomString($length) { + $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $charactersLength = strlen($characters); + $randomString = ''; + for ($i = 0; $i < $length; $i++) { + $randomString .= $characters[rand(0, $charactersLength - 1)]; + } + return $randomString; + } + +} diff --git a/modules/gdpr_tasks/src/Event/GetAnonymizersEvent.php b/modules/gdpr_tasks/src/Event/GetAnonymizersEvent.php new file mode 100644 index 0000000..c3674ff --- /dev/null +++ b/modules/gdpr_tasks/src/Event/GetAnonymizersEvent.php @@ -0,0 +1,37 @@ +anonymizers = [ + 'string' => ['\Drupal\gdpr_tasks\Anonymizer', 'anonymizeString'], + 'datetime' => ['\Drupal\gdpr_tasks\Anonymizer', 'anonymizeDate'], + ]; + } +} \ No newline at end of file diff --git a/modules/gdpr_tasks/src/Form/TaskActionsForm.php b/modules/gdpr_tasks/src/Form/TaskActionsForm.php index 7853206..e789a9f 100644 --- a/modules/gdpr_tasks/src/Form/TaskActionsForm.php +++ b/modules/gdpr_tasks/src/Form/TaskActionsForm.php @@ -2,8 +2,10 @@ namespace Drupal\gdpr_tasks\Form; +use Drupal\Console\Bootstrap\Drupal; use Drupal\Core\Entity\ContentEntityForm; use Drupal\Core\Form\FormStateInterface; +use Drupal\user\Entity\User; /** * Form controller for Task edit forms. @@ -30,28 +32,9 @@ public function buildForm(array $form, FormStateInterface $form_state) { } /** - * {@inheritdoc} - */ - protected function actions(array $form, FormStateInterface $form_state) { - $actions = parent::actions($form, $form_state); - - if (isset($actions['delete'])) { - unset($actions['delete']); - } - - if (isset($actions['submit'])) { - $actions['submit']['#value'] = 'Process'; - } - - return $actions; - } - - /** - * {@inheritdoc} + * Performs the SAR export. */ - public function submitForm(array &$form, FormStateInterface $form_state) { - parent::submitForm($form, $form_state); - + private function doSarExport(FormStateInterface $form_state): void { $entity = $this->entity; $manual = $form_state->getValue(['manual_data', 0, 'value']); @@ -81,16 +64,93 @@ public function submitForm(array &$form, FormStateInterface $form_state) { file_save_data($export, $file_uri, FILE_EXISTS_REPLACE); } + /** + * Performs the removal request. + */ + private function doRemoval(FormStateInterface $form_state) { + // @todo Should be injected + $anonymizer = \Drupal::service('gdpr_tasks.anonymizer'); + // Make sure to load a new copy of the user. + // Do not modify the original instance, or you'll see anonymized data + // in the Requested By field. Use loadUnchanged to bypass the cache + // and retrieve a fresh instance. +// $user_made_request = $this->entity->getOwner(); + +// $user = $this->entityManager->getStorage($user_made_request->getEntityTypeId()) +// ->loadUnchanged($user_made_request->id()); + $errors = $anonymizer->run($this->entity->getOwner()); + return $errors; + } + + /** + * {@inheritdoc} + */ + protected function actions(array $form, FormStateInterface $form_state) { + $actions = parent::actions($form, $form_state); + + if (isset($actions['delete'])) { + unset($actions['delete']); + } + + if (isset($actions['submit'])) { + if ($this->entity->bundle() == 'gdpr_remove') { + $actions['submit']['#value'] = 'Remove and Anonymise Data'; + $actions['submit']['#name'] = 'remove'; + } + else { + $actions['submit']['#value'] = 'Process'; + $actions['submit']['#name'] = 'export'; + } + } + + return $actions; + } + + /** + * {@inheritdoc} + */ + // public function submitForm(array &$form, FormStateInterface $form_state) { + // parent::submitForm($form, $form_state); + // + // $operation = $form_state->getTriggeringElement()['#name']; + // + // switch ($operation) { + // case 'export': + // $this->doSarExport($form_state); + // break; + // + // case 'remove': + // $this->doRemoval($form_state); + // break; + // } + // } + /** * {@inheritdoc} */ public function save(array $form, FormStateInterface $form_state) { /* @var $entity \Drupal\gdpr_tasks\Entity\Task */ $entity = $this->entity; - $entity->status = 'closed'; - \Drupal::messenger()->addStatus('Task has been processed.'); - parent::save($form, $form_state); + if ($entity->bundle() == 'gdpr_remove') { + $errors = $this->doRemoval($form_state); + // Removals may have generated errors. + // If this happens, combine the error messages and display them. + if (count($errors) > 0) { + \Drupal::messenger()->addError(implode(' ', $errors)); + $form_state->setRebuild(); + } + else { + $entity->status = 'closed'; + parent::save($form, $form_state); + } + } + else { + $this->doSarExport($form_state); + $entity->status = 'closed'; + \Drupal::messenger()->addStatus('Task has been processed.'); + parent::save($form, $form_state); + } } } From e4a9e708be3576c620d27215dfc9986533ab0b22 Mon Sep 17 00:00:00 2001 From: Jeremy Skinner Date: Thu, 8 Mar 2018 16:52:34 +0000 Subject: [PATCH 02/16] Ensure entity cache is bypassed when modifying fields as part of anonymization --- modules/gdpr_tasks/gdpr_tasks.services.yml | 2 +- modules/gdpr_tasks/src/Anonymizer.php | 20 +++++++++++++++++-- .../gdpr_tasks/src/Form/TaskActionsForm.php | 2 +- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/modules/gdpr_tasks/gdpr_tasks.services.yml b/modules/gdpr_tasks/gdpr_tasks.services.yml index a0ecf55..6719fee 100644 --- a/modules/gdpr_tasks/gdpr_tasks.services.yml +++ b/modules/gdpr_tasks/gdpr_tasks.services.yml @@ -4,4 +4,4 @@ services: arguments: ['@entity_type.manager', '@current_user'] gdpr_tasks.anonymizer: class: Drupal\gdpr_tasks\Anonymizer - arguments: ['@gdpr_fields.collector', '@event_dispatcher', '@database'] \ No newline at end of file + arguments: ['@gdpr_fields.collector', '@event_dispatcher', '@database', '@entity_type.manager'] \ No newline at end of file diff --git a/modules/gdpr_tasks/src/Anonymizer.php b/modules/gdpr_tasks/src/Anonymizer.php index 73244e8..b4cd9ed 100644 --- a/modules/gdpr_tasks/src/Anonymizer.php +++ b/modules/gdpr_tasks/src/Anonymizer.php @@ -4,6 +4,8 @@ use Drupal\Core\Database\Connection; use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\EntityManager; +use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\TypedData\Exception\ReadOnlyException; use Drupal\gdpr_tasks\Event\GetAnonymizersEvent; @@ -22,19 +24,29 @@ class Anonymizer { private $db; + private $entityTypeManager; + /** * Anonymizer constructor. */ - public function __construct(GDPRCollector $collector, EventDispatcherInterface $dispatcher, Connection $db) { + public function __construct(GDPRCollector $collector, EventDispatcherInterface $dispatcher, Connection $db, EntityTypeManagerInterface $entity_manager) { $this->collector = $collector; $this->dispatcher = $dispatcher; $this->db = $db; + $this->entityTypeManager = $entity_manager; } /** * Runs anonymization routines against a user. */ - public function run(User $user) { + public function run($user_id) { + + // Make sure we load a fresh copy of the entity (bypassing the cache) + // so we don't end up affecting any other references to the entity. + + $user = $this->entityTypeManager->getStorage('user') + ->loadUnchanged($user_id); + $errors = []; $entities = []; $successes = []; @@ -44,6 +56,10 @@ public function run(User $user) { foreach ($entities as $entity_type => $bundles) { foreach ($bundles as $bundle_entity) { + // Re-load a fresh copy of the bundle entity from storage so we don't + // end up modifying any other references to the entity in memory. + $bundle_entity = $this->entityTypeManager->getStorage($bundle_entity->getEntityTypeId()) + ->loadUnchanged($bundle_entity->id()); $entity_success = TRUE; diff --git a/modules/gdpr_tasks/src/Form/TaskActionsForm.php b/modules/gdpr_tasks/src/Form/TaskActionsForm.php index e789a9f..d314e99 100644 --- a/modules/gdpr_tasks/src/Form/TaskActionsForm.php +++ b/modules/gdpr_tasks/src/Form/TaskActionsForm.php @@ -78,7 +78,7 @@ private function doRemoval(FormStateInterface $form_state) { // $user = $this->entityManager->getStorage($user_made_request->getEntityTypeId()) // ->loadUnchanged($user_made_request->id()); - $errors = $anonymizer->run($this->entity->getOwner()); + $errors = $anonymizer->run($this->entity->getOwner()->id()); return $errors; } From 7d5f84c690c4d5b8d1570047292bc6e46fd38099 Mon Sep 17 00:00:00 2001 From: Jeremy Skinner Date: Mon, 12 Mar 2018 09:19:57 +0000 Subject: [PATCH 03/16] Switch from event to custom hook for anonymizers --- modules/gdpr_tasks/gdpr_tasks.services.yml | 2 +- modules/gdpr_tasks/src/Anonymizer.php | 28 +++++++------- .../src/Event/GetAnonymizersEvent.php | 37 ------------------- 3 files changed, 15 insertions(+), 52 deletions(-) delete mode 100644 modules/gdpr_tasks/src/Event/GetAnonymizersEvent.php diff --git a/modules/gdpr_tasks/gdpr_tasks.services.yml b/modules/gdpr_tasks/gdpr_tasks.services.yml index 6719fee..962263d 100644 --- a/modules/gdpr_tasks/gdpr_tasks.services.yml +++ b/modules/gdpr_tasks/gdpr_tasks.services.yml @@ -4,4 +4,4 @@ services: arguments: ['@entity_type.manager', '@current_user'] gdpr_tasks.anonymizer: class: Drupal\gdpr_tasks\Anonymizer - arguments: ['@gdpr_fields.collector', '@event_dispatcher', '@database', '@entity_type.manager'] \ No newline at end of file + arguments: ['@gdpr_fields.collector', '@database', '@entity_type.manager', '@module_handler'] \ No newline at end of file diff --git a/modules/gdpr_tasks/src/Anonymizer.php b/modules/gdpr_tasks/src/Anonymizer.php index b4cd9ed..aeb92bb 100644 --- a/modules/gdpr_tasks/src/Anonymizer.php +++ b/modules/gdpr_tasks/src/Anonymizer.php @@ -4,14 +4,11 @@ use Drupal\Core\Database\Connection; use Drupal\Core\Entity\EntityInterface; -use Drupal\Core\Entity\EntityManager; use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\TypedData\Exception\ReadOnlyException; -use Drupal\gdpr_tasks\Event\GetAnonymizersEvent; use Drupal\gdpr_fields\GDPRCollector; -use Drupal\user\Entity\User; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; /** * Anonymizes or removes field values for GDPR. @@ -20,20 +17,20 @@ class Anonymizer { private $collector; - private $dispatcher; - private $db; private $entityTypeManager; + private $moduleHandler; + /** * Anonymizer constructor. */ - public function __construct(GDPRCollector $collector, EventDispatcherInterface $dispatcher, Connection $db, EntityTypeManagerInterface $entity_manager) { + public function __construct(GDPRCollector $collector, Connection $db, EntityTypeManagerInterface $entity_manager, ModuleHandlerInterface $module_handler) { $this->collector = $collector; - $this->dispatcher = $dispatcher; $this->db = $db; $this->entityTypeManager = $entity_manager; + $this->moduleHandler = $module_handler; } /** @@ -143,14 +140,17 @@ private function remove(FieldItemListInterface $field) { private function anonymize(FieldItemListInterface $field) { // For string fields, generate a random string the same length. $type = $field->getFieldDefinition()->getType(); - $event = new GetAnonymizersEvent(); - // Dispatch the event to allow other modules - // to register anonymization functions for particular entity types. - $this->dispatcher->dispatch(GetAnonymizersEvent::EVENT_NAME, $event); - if (isset($event->anonymizers[$type]) && is_callable($event->anonymizers[$type])) { + $anonymizers = [ + 'string' => ['\Drupal\gdpr_tasks\Anonymizer', 'anonymizeString'], + 'datetime' => ['\Drupal\gdpr_tasks\Anonymizer', 'anonymizeDate'], + ]; + + $this->moduleHandler->alter('gdpr_anonymizers', $anonymizers); + + if (isset($anonymizers[$type]) && is_callable($anonymizers[$type])) { try { - call_user_func($event->anonymizers[$type], $field); + call_user_func($anonymizers[$type], $field); return [TRUE, NULL]; } catch (\Exception $e) { diff --git a/modules/gdpr_tasks/src/Event/GetAnonymizersEvent.php b/modules/gdpr_tasks/src/Event/GetAnonymizersEvent.php deleted file mode 100644 index c3674ff..0000000 --- a/modules/gdpr_tasks/src/Event/GetAnonymizersEvent.php +++ /dev/null @@ -1,37 +0,0 @@ -anonymizers = [ - 'string' => ['\Drupal\gdpr_tasks\Anonymizer', 'anonymizeString'], - 'datetime' => ['\Drupal\gdpr_tasks\Anonymizer', 'anonymizeDate'], - ]; - } -} \ No newline at end of file From 70f629713f8239c701b8062b3aaf263bb03e76f1 Mon Sep 17 00:00:00 2001 From: Jeremy Skinner Date: Mon, 12 Mar 2018 13:36:41 +0000 Subject: [PATCH 04/16] Suppress error if cache hasn't been initialized yet --- gdpr.module | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/gdpr.module b/gdpr.module index 24175d4..dd17037 100644 --- a/gdpr.module +++ b/gdpr.module @@ -448,6 +448,10 @@ function _get_module_list_by_type($type) { function _get_modules() { $cache = \Drupal::cache()->get('modules.list'); + if (!$cache) { + return []; + } + if ($cache->data) { return $cache->data; } From ad47726d5d9257c33a9f78fd24cded409dbe4ed0 Mon Sep 17 00:00:00 2001 From: Jeremy Skinner Date: Mon, 12 Mar 2018 13:42:09 +0000 Subject: [PATCH 05/16] Implement anonymization of base User fields and account blocking --- modules/gdpr_tasks/gdpr_tasks.module | 72 +++++++++++++++- modules/gdpr_tasks/gdpr_tasks.services.yml | 2 +- modules/gdpr_tasks/src/Anonymizer.php | 85 ++++++++++++++----- .../gdpr_tasks/src/Form/TaskActionsForm.php | 27 ------ 4 files changed, 136 insertions(+), 50 deletions(-) diff --git a/modules/gdpr_tasks/gdpr_tasks.module b/modules/gdpr_tasks/gdpr_tasks.module index 783a5ca..dffc555 100644 --- a/modules/gdpr_tasks/gdpr_tasks.module +++ b/modules/gdpr_tasks/gdpr_tasks.module @@ -192,4 +192,74 @@ function gdpr_tasks_entity_presave(EntityInterface $entity) { } break; } -} \ No newline at end of file +} + +/** + * Implements hook_entity_base_field_info_alter(). + * + * Ensures that anonymize/removal settings are always set for username, email + * password and roles. These fields are not UI-editable so we set these in code. + */ +function gdpr_tasks_entity_base_field_info_alter(&$fields, \Drupal\Core\Entity\EntityTypeInterface $entity_type, $bundle) { + if ($entity_type->id() == 'user') { + $fields['mail']['third_party_settings']['gdpr_fields']['gdpr_fields_enabled'] = TRUE; + $fields['mail']['third_party_settings']['gdpr_fields']['gdpr_fields_rta'] = 'inc'; + $fields['mail']['third_party_settings']['gdpr_fields']['gdpr_fields_rtf'] = 'anonymise'; + + $fields['name']['third_party_settings']['gdpr_fields']['gdpr_fields_enabled'] = TRUE; + $fields['name']['third_party_settings']['gdpr_fields']['gdpr_fields_rta'] = 'inc'; + $fields['name']['third_party_settings']['gdpr_fields']['gdpr_fields_rtf'] = 'anonymise'; + + $fields['roles']['third_party_settings']['gdpr_fields']['gdpr_fields_enabled'] = TRUE; + $fields['roles']['third_party_settings']['gdpr_fields']['gdpr_fields_rta'] = 'no'; + $fields['roles']['third_party_settings']['gdpr_fields']['gdpr_fields_rtf'] = 'remove'; + } +} + +/** + * Implements hook_entity_base_field_info(). + * + * Ensures that custom GDPR fields are associated with the User entity. + * These fields are gdpr_date_removed and gdpr_removed_by + * which are filled in when a request to anonymize/remove is completed. + */ +function gdpr_tasks_entity_base_field_info(\Drupal\Core\Entity\EntityTypeInterface $entity_type) { + if ($entity_type->id() == 'user') { + $fields['gdpr_date_removed'] = \Drupal\Core\Field\BaseFieldDefinition::create('datetime') + ->setName('gdpr_date_removed') + ->setLabel('GDPR Date Removed') + ->setTargetEntityTypeId('user') + ->setDisplayConfigurable("form", FALSE) + ->setDisplayConfigurable("view", FALSE); + + $fields['gdpr_removed_by'] = \Drupal\Core\Field\BaseFieldDefinition::create('string') + ->setName('gdpr_removed_by') + ->setLabel('GDPR Removed By') + ->setTargetEntityTypeId('user') + ->setDisplayConfigurable("form", FALSE) + ->setDisplayConfigurable("view", FALSE); + + return $fields; + } +} + +/** + * Implements hook_entity_field_storage_info(). + * + * Ensures that custom GDPR fields are associated with the User entity. + * These fields are gdpr_date_removed and gdpr_removed_by + * which are filled in when a request to anonymize/remove is completed. + */ +function gdpr_tasks_entity_field_storage_info(\Drupal\Core\Entity\EntityTypeInterface $entity_type) { + if($entity_type->id() == 'user') { + $storage['gdpr_date_removed'] = \Drupal\Core\Field\BaseFieldDefinition::create('datetime') + ->setName('gdpr_date_removed') + ->setCardinality(1); + + $storage['gdpr_removed_by'] = \Drupal\Core\Field\BaseFieldDefinition::create('string') + ->setName('gdpr_removed_by') + ->setCardinality(1); + + return $storage; + } +} diff --git a/modules/gdpr_tasks/gdpr_tasks.services.yml b/modules/gdpr_tasks/gdpr_tasks.services.yml index 962263d..1c09777 100644 --- a/modules/gdpr_tasks/gdpr_tasks.services.yml +++ b/modules/gdpr_tasks/gdpr_tasks.services.yml @@ -4,4 +4,4 @@ services: arguments: ['@entity_type.manager', '@current_user'] gdpr_tasks.anonymizer: class: Drupal\gdpr_tasks\Anonymizer - arguments: ['@gdpr_fields.collector', '@database', '@entity_type.manager', '@module_handler'] \ No newline at end of file + arguments: ['@gdpr_fields.collector', '@database', '@entity_type.manager', '@module_handler', '@current_user'] \ No newline at end of file diff --git a/modules/gdpr_tasks/src/Anonymizer.php b/modules/gdpr_tasks/src/Anonymizer.php index aeb92bb..093a7ee 100644 --- a/modules/gdpr_tasks/src/Anonymizer.php +++ b/modules/gdpr_tasks/src/Anonymizer.php @@ -7,8 +7,10 @@ use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\Field\FieldItemListInterface; +use Drupal\Core\Session\AccountProxyInterface; use Drupal\Core\TypedData\Exception\ReadOnlyException; use Drupal\gdpr_fields\GDPRCollector; +use Drupal\user\Entity\User; /** * Anonymizes or removes field values for GDPR. @@ -23,26 +25,26 @@ class Anonymizer { private $moduleHandler; + private $currentUser; + /** * Anonymizer constructor. */ - public function __construct(GDPRCollector $collector, Connection $db, EntityTypeManagerInterface $entity_manager, ModuleHandlerInterface $module_handler) { + public function __construct(GDPRCollector $collector, Connection $db, EntityTypeManagerInterface $entity_manager, ModuleHandlerInterface $module_handler, AccountProxyInterface $current_user) { $this->collector = $collector; $this->db = $db; $this->entityTypeManager = $entity_manager; $this->moduleHandler = $module_handler; + $this->currentUser = $current_user; } /** * Runs anonymization routines against a user. */ public function run($user_id) { - // Make sure we load a fresh copy of the entity (bypassing the cache) // so we don't end up affecting any other references to the entity. - - $user = $this->entityTypeManager->getStorage('user') - ->loadUnchanged($user_id); + $user = $this->refetchUser($user_id); $errors = []; $entities = []; @@ -69,7 +71,7 @@ public function run($user_id) { $msg = NULL; if ($mode == 'anonymise') { - list($success, $msg) = $this->anonymize($field); + list($success, $msg) = $this->anonymize($field, $bundle_entity, $entity_type); } elseif ($mode == 'remove') { list($success, $msg) = $this->remove($field); @@ -96,9 +98,18 @@ public function run($user_id) { $tx = $this->db->startTransaction(); try { + /* @var EntityInterface $entity */ foreach ($successes as $entity) { $entity->save(); } + // Re-fetch the user so we see any changes that were made. + $user = $this->refetchUser($user_id); + + // Log that this user has been anonymized and block the account. + $user->get('gdpr_date_removed')->setValue(date("Y-m-d H:i:s")); + $user->get('gdpr_removed_by')->setValue($this->currentUser->id()); + $user->block(); + $user->save(); } catch (\Exception $e) { $tx->rollBack(); @@ -106,16 +117,6 @@ public function run($user_id) { } } - // @todo this - // Now we've finished processing any defined fields. - // We must always process the following: - // - Anonymize the username - // - Anonymize the email - // - Remove the password - // - Remove all roles - // - Block the user - // - Store that they've been anonymized. - return $errors; } @@ -137,20 +138,38 @@ private function remove(FieldItemListInterface $field) { * * Uses the GetAnonymizersEvent to find an appropriate anonymization function. */ - private function anonymize(FieldItemListInterface $field) { + private function anonymize(FieldItemListInterface $field, EntityInterface $bundle_entity, $entity_type) { // For string fields, generate a random string the same length. $type = $field->getFieldDefinition()->getType(); + $field_key = implode('.', array_filter( + [$entity_type, $bundle_entity->bundle(), $field->getName()])); $anonymizers = [ - 'string' => ['\Drupal\gdpr_tasks\Anonymizer', 'anonymizeString'], - 'datetime' => ['\Drupal\gdpr_tasks\Anonymizer', 'anonymizeDate'], + 'field' => [ + 'user.user.mail' => ['\Drupal\gdpr_tasks\Anonymizer', 'anonymizeMail'], + ], + 'type' => [ + 'string' => ['\Drupal\gdpr_tasks\Anonymizer', 'anonymizeString'], + 'datetime' => ['\Drupal\gdpr_tasks\Anonymizer', 'anonymizeDate'], + ], ]; $this->moduleHandler->alter('gdpr_anonymizers', $anonymizers); - if (isset($anonymizers[$type]) && is_callable($anonymizers[$type])) { + // Check if there's an anonymizer for this field. + if (isset($anonymizers['field'][$field_key]) && is_callable($anonymizers['field'][$field_key])) { try { - call_user_func($anonymizers[$type], $field); + call_user_func($anonymizers['field'][$field_key], $field); + return [TRUE, NULL]; + } + catch (\Exception $e) { + return [FALSE, $e->getMessage()]; + } + } + // If not, anonymize by type instead. + elseif (isset($anonymizers['type'][$type]) && is_callable($anonymizers['type'][$type])) { + try { + call_user_func($anonymizers['type'][$type], $field); return [TRUE, NULL]; } catch (\Exception $e) { @@ -199,6 +218,30 @@ private function getFieldsToProcess(EntityInterface $entity) { return $fields; } + /** + * Re-fetches the user bypassing the cache. + * + * @return \Drupal\user\Entity\User + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + */ + private function refetchUser($user_id) { + return $this->entityTypeManager->getStorage('user') + ->loadUnchanged($user_id); + } + + /** + * Generates an unique email address. + * + * Uses the timestamp to ensure this is unique. + * + * @throws \Drupal\Core\TypedData\Exception\ReadOnlyException + */ + public static function anonymizeMail(FieldItemListInterface $field) { + $mail = 'anon_' . time() . '@example.com'; + $field->setValue($mail); + } + /** * Replaces string field value with a random value if the field is non-empty. * diff --git a/modules/gdpr_tasks/src/Form/TaskActionsForm.php b/modules/gdpr_tasks/src/Form/TaskActionsForm.php index d314e99..8e440ee 100644 --- a/modules/gdpr_tasks/src/Form/TaskActionsForm.php +++ b/modules/gdpr_tasks/src/Form/TaskActionsForm.php @@ -70,14 +70,6 @@ private function doSarExport(FormStateInterface $form_state): void { private function doRemoval(FormStateInterface $form_state) { // @todo Should be injected $anonymizer = \Drupal::service('gdpr_tasks.anonymizer'); - // Make sure to load a new copy of the user. - // Do not modify the original instance, or you'll see anonymized data - // in the Requested By field. Use loadUnchanged to bypass the cache - // and retrieve a fresh instance. -// $user_made_request = $this->entity->getOwner(); - -// $user = $this->entityManager->getStorage($user_made_request->getEntityTypeId()) -// ->loadUnchanged($user_made_request->id()); $errors = $anonymizer->run($this->entity->getOwner()->id()); return $errors; } @@ -106,25 +98,6 @@ protected function actions(array $form, FormStateInterface $form_state) { return $actions; } - /** - * {@inheritdoc} - */ - // public function submitForm(array &$form, FormStateInterface $form_state) { - // parent::submitForm($form, $form_state); - // - // $operation = $form_state->getTriggeringElement()['#name']; - // - // switch ($operation) { - // case 'export': - // $this->doSarExport($form_state); - // break; - // - // case 'remove': - // $this->doRemoval($form_state); - // break; - // } - // } - /** * {@inheritdoc} */ From 82bd0cb72b38740e2f2dea7a1bd78843a517f30f Mon Sep 17 00:00:00 2001 From: Jeremy Skinner Date: Mon, 12 Mar 2018 15:03:57 +0000 Subject: [PATCH 06/16] Add edit link for maybe fields in RTF --- modules/gdpr_fields/src/GDPRCollector.php | 7 +++++++ modules/gdpr_tasks/gdpr_task.page.inc | 1 + modules/gdpr_tasks/gdpr_tasks.module | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/modules/gdpr_fields/src/GDPRCollector.php b/modules/gdpr_fields/src/GDPRCollector.php index 895e6a9..f15b661 100644 --- a/modules/gdpr_fields/src/GDPRCollector.php +++ b/modules/gdpr_fields/src/GDPRCollector.php @@ -308,6 +308,13 @@ public function fieldValues($entity_type = 'user', EntityInterface $entity, $ext if ($rtf_value && $rtf_value !== 'no') { $fields[$key]['gdpr_rtf'] = $rtf_value; + // For 'maybes', provide a link to edit the entity. + if ($rtf_value == 'maybe') { + $fields[$key]['link'] = $entity->toLink('Edit', 'edit-form'); + } + else { + $fields[$key]['link'] = ''; + } } else { unset($fields[$key]); diff --git a/modules/gdpr_tasks/gdpr_task.page.inc b/modules/gdpr_tasks/gdpr_task.page.inc index 37b4f3d..9328872 100644 --- a/modules/gdpr_tasks/gdpr_task.page.inc +++ b/modules/gdpr_tasks/gdpr_task.page.inc @@ -44,6 +44,7 @@ function template_preprocess_gdpr_task(array &$variables) { t('Entity'), t('Bundle'), t('Right to be forgotten'), + '', ], '#rows' => $rows, '#sticky' => TRUE, diff --git a/modules/gdpr_tasks/gdpr_tasks.module b/modules/gdpr_tasks/gdpr_tasks.module index dffc555..70869f4 100644 --- a/modules/gdpr_tasks/gdpr_tasks.module +++ b/modules/gdpr_tasks/gdpr_tasks.module @@ -200,7 +200,7 @@ function gdpr_tasks_entity_presave(EntityInterface $entity) { * Ensures that anonymize/removal settings are always set for username, email * password and roles. These fields are not UI-editable so we set these in code. */ -function gdpr_tasks_entity_base_field_info_alter(&$fields, \Drupal\Core\Entity\EntityTypeInterface $entity_type, $bundle) { +function gdpr_tasks_entity_base_field_info_alter(&$fields, \Drupal\Core\Entity\EntityTypeInterface $entity_type) { if ($entity_type->id() == 'user') { $fields['mail']['third_party_settings']['gdpr_fields']['gdpr_fields_enabled'] = TRUE; $fields['mail']['third_party_settings']['gdpr_fields']['gdpr_fields_rta'] = 'inc'; From 722e8fbec571acae4cd6eed15bb4e3c1ceaeb251 Mon Sep 17 00:00:00 2001 From: Jeremy Skinner Date: Mon, 12 Mar 2018 16:14:03 +0000 Subject: [PATCH 07/16] Move status fields to the Task. Store processed by on the task. --- modules/gdpr_tasks/gdpr_tasks.module | 48 ---------------- modules/gdpr_tasks/src/Anonymizer.php | 11 ++-- modules/gdpr_tasks/src/Entity/Task.php | 57 ++++++++++++++++++- .../gdpr_tasks/src/Form/TaskActionsForm.php | 11 +++- 4 files changed, 68 insertions(+), 59 deletions(-) diff --git a/modules/gdpr_tasks/gdpr_tasks.module b/modules/gdpr_tasks/gdpr_tasks.module index 70869f4..4065e89 100644 --- a/modules/gdpr_tasks/gdpr_tasks.module +++ b/modules/gdpr_tasks/gdpr_tasks.module @@ -215,51 +215,3 @@ function gdpr_tasks_entity_base_field_info_alter(&$fields, \Drupal\Core\Entity\E $fields['roles']['third_party_settings']['gdpr_fields']['gdpr_fields_rtf'] = 'remove'; } } - -/** - * Implements hook_entity_base_field_info(). - * - * Ensures that custom GDPR fields are associated with the User entity. - * These fields are gdpr_date_removed and gdpr_removed_by - * which are filled in when a request to anonymize/remove is completed. - */ -function gdpr_tasks_entity_base_field_info(\Drupal\Core\Entity\EntityTypeInterface $entity_type) { - if ($entity_type->id() == 'user') { - $fields['gdpr_date_removed'] = \Drupal\Core\Field\BaseFieldDefinition::create('datetime') - ->setName('gdpr_date_removed') - ->setLabel('GDPR Date Removed') - ->setTargetEntityTypeId('user') - ->setDisplayConfigurable("form", FALSE) - ->setDisplayConfigurable("view", FALSE); - - $fields['gdpr_removed_by'] = \Drupal\Core\Field\BaseFieldDefinition::create('string') - ->setName('gdpr_removed_by') - ->setLabel('GDPR Removed By') - ->setTargetEntityTypeId('user') - ->setDisplayConfigurable("form", FALSE) - ->setDisplayConfigurable("view", FALSE); - - return $fields; - } -} - -/** - * Implements hook_entity_field_storage_info(). - * - * Ensures that custom GDPR fields are associated with the User entity. - * These fields are gdpr_date_removed and gdpr_removed_by - * which are filled in when a request to anonymize/remove is completed. - */ -function gdpr_tasks_entity_field_storage_info(\Drupal\Core\Entity\EntityTypeInterface $entity_type) { - if($entity_type->id() == 'user') { - $storage['gdpr_date_removed'] = \Drupal\Core\Field\BaseFieldDefinition::create('datetime') - ->setName('gdpr_date_removed') - ->setCardinality(1); - - $storage['gdpr_removed_by'] = \Drupal\Core\Field\BaseFieldDefinition::create('string') - ->setName('gdpr_removed_by') - ->setCardinality(1); - - return $storage; - } -} diff --git a/modules/gdpr_tasks/src/Anonymizer.php b/modules/gdpr_tasks/src/Anonymizer.php index 093a7ee..f1a0499 100644 --- a/modules/gdpr_tasks/src/Anonymizer.php +++ b/modules/gdpr_tasks/src/Anonymizer.php @@ -10,6 +10,7 @@ use Drupal\Core\Session\AccountProxyInterface; use Drupal\Core\TypedData\Exception\ReadOnlyException; use Drupal\gdpr_fields\GDPRCollector; +use Drupal\gdpr_tasks\Entity\TaskInterface; use Drupal\user\Entity\User; /** @@ -41,10 +42,10 @@ public function __construct(GDPRCollector $collector, Connection $db, EntityType /** * Runs anonymization routines against a user. */ - public function run($user_id) { + public function run(TaskInterface $task) { // Make sure we load a fresh copy of the entity (bypassing the cache) // so we don't end up affecting any other references to the entity. - $user = $this->refetchUser($user_id); + $user = $task->getOwner(); //$this->refetchUser($task->getOwnerId()); $errors = []; $entities = []; @@ -103,11 +104,7 @@ public function run($user_id) { $entity->save(); } // Re-fetch the user so we see any changes that were made. - $user = $this->refetchUser($user_id); - - // Log that this user has been anonymized and block the account. - $user->get('gdpr_date_removed')->setValue(date("Y-m-d H:i:s")); - $user->get('gdpr_removed_by')->setValue($this->currentUser->id()); + $user = $this->refetchUser($task->getOwnerId()); $user->block(); $user->save(); } diff --git a/modules/gdpr_tasks/src/Entity/Task.php b/modules/gdpr_tasks/src/Entity/Task.php index fdd5c45..3fa213d 100644 --- a/modules/gdpr_tasks/src/Entity/Task.php +++ b/modules/gdpr_tasks/src/Entity/Task.php @@ -112,6 +112,36 @@ public function setOwner(UserInterface $account) { return $this; } + /** + * {@inheritdoc} + */ + public function setProcessedBy(UserInterface $account) { + $this->set('processed_by', $account->id()); + return $this; + } + + /** + * {@inheritdoc} + */ + public function setProcessedById($uid) { + $this->set('processed_by', $uid); + return $this; + } + + /** + * {@inheritdoc} + */ + public function getProcessedBy() { + return $this->get('processed_by')->entity; + } + + /** + * {@inheritdoc} + */ + public function getProcessedById() { + return $this->get('processed_by')->target_id; + } + /** * {@inheritdoc} */ @@ -184,7 +214,32 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { $fields['complete'] = BaseFieldDefinition::create('changed') ->setLabel(t('Changed')) - ->setDescription(t('The time that the entity was last edited.')); + ->setDescription(t('The time that the task was completed.')); + + $fields['processed_by'] = BaseFieldDefinition::create('entity_reference') + ->setLabel(t('Processed by')) + ->setDescription(t('The user who processed this task.')) + ->setRevisionable(TRUE) + ->setSetting('target_type', 'user') + ->setSetting('handler', 'default') + ->setTranslatable(TRUE) + ->setDisplayOptions('view', [ + 'label' => 'hidden', + 'type' => 'author', + 'weight' => 0, + ]) + ->setDisplayOptions('form', [ + 'type' => 'hidden', + 'weight' => 5, + 'settings' => [ + 'match_operator' => 'CONTAINS', + 'size' => '60', + 'autocomplete_type' => 'tags', + 'placeholder' => '', + ], + ]) + ->setDisplayConfigurable('form', TRUE) + ->setDisplayConfigurable('view', TRUE); return $fields; } diff --git a/modules/gdpr_tasks/src/Form/TaskActionsForm.php b/modules/gdpr_tasks/src/Form/TaskActionsForm.php index 8e440ee..4ae5795 100644 --- a/modules/gdpr_tasks/src/Form/TaskActionsForm.php +++ b/modules/gdpr_tasks/src/Form/TaskActionsForm.php @@ -70,7 +70,7 @@ private function doSarExport(FormStateInterface $form_state): void { private function doRemoval(FormStateInterface $form_state) { // @todo Should be injected $anonymizer = \Drupal::service('gdpr_tasks.anonymizer'); - $errors = $anonymizer->run($this->entity->getOwner()->id()); + $errors = $anonymizer->run($this->entity); return $errors; } @@ -110,17 +110,22 @@ public function save(array $form, FormStateInterface $form_state) { // Removals may have generated errors. // If this happens, combine the error messages and display them. if (count($errors) > 0) { + $should_save = FALSE; \Drupal::messenger()->addError(implode(' ', $errors)); $form_state->setRebuild(); } else { - $entity->status = 'closed'; - parent::save($form, $form_state); + $should_save = TRUE; } } else { $this->doSarExport($form_state); + $should_save = TRUE; + } + + if ($should_save) { $entity->status = 'closed'; + $entity->setProcessedById(\Drupal::currentUser()->id()); \Drupal::messenger()->addStatus('Task has been processed.'); parent::save($form, $form_state); } From 2638016d6b2f3a30126a2162b11b105ca1ca03fd Mon Sep 17 00:00:00 2001 From: Jeremy Skinner Date: Tue, 13 Mar 2018 11:44:31 +0000 Subject: [PATCH 08/16] Introduce TaskLogItem to log which fields were changed by Remove tasks. --- modules/gdpr_fields/src/GDPRCollector.php | 7 +- ..._display.gdpr_task.gdpr_remove.default.yml | 1 + ..._display.gdpr_task.gdpr_remove.default.yml | 16 +++++ ...iew_display.gdpr_task.gdpr_sar.default.yml | 7 ++ ...ield.gdpr_task.gdpr_remove.removal_log.yml | 20 ++++++ .../field.storage.gdpr_task.removal_log.yml | 17 +++++ modules/gdpr_tasks/src/Anonymizer.php | 16 ++++- modules/gdpr_tasks/src/Entity/Task.php | 3 +- .../FieldFormatter/TaskLogItemFormatter.php | 37 +++++++++++ .../Plugin/Field/FieldType/TaskLogItem.php | 65 +++++++++++++++++++ .../Field/FieldWidget/TaskLogItemWidget.php | 51 +++++++++++++++ 11 files changed, 235 insertions(+), 5 deletions(-) create mode 100644 modules/gdpr_tasks/config/install/field.field.gdpr_task.gdpr_remove.removal_log.yml create mode 100644 modules/gdpr_tasks/config/install/field.storage.gdpr_task.removal_log.yml create mode 100644 modules/gdpr_tasks/src/Plugin/Field/FieldFormatter/TaskLogItemFormatter.php create mode 100644 modules/gdpr_tasks/src/Plugin/Field/FieldType/TaskLogItem.php create mode 100644 modules/gdpr_tasks/src/Plugin/Field/FieldWidget/TaskLogItemWidget.php diff --git a/modules/gdpr_fields/src/GDPRCollector.php b/modules/gdpr_fields/src/GDPRCollector.php index f15b661..e9e681a 100644 --- a/modules/gdpr_fields/src/GDPRCollector.php +++ b/modules/gdpr_fields/src/GDPRCollector.php @@ -140,13 +140,18 @@ public function getValueEntities(array &$entity_list, $entity_type, EntityInterf foreach ($definitions as $definition_id => $definition) { - list($type, $definition_entity, ,) = explode(':', $definition_id); + list($type, $definition_entity, $related_entity_type,) = explode(':', $definition_id); // Ignore entity revisions for now. if ($definition_entity == 'entity_revision') { continue; } + // Ignore links back to gdpr_task. + if ($related_entity_type == 'gdpr_task') { + continue; + } + if ($type == 'typed_data_entity_relationship') { /* @var \Drupal\ctools\Plugin\Relationship\TypedDataEntityRelationship $plugin */ $plugin = $this->relationshipManager->createInstance($definition_id); diff --git a/modules/gdpr_tasks/config/install/core.entity_form_display.gdpr_task.gdpr_remove.default.yml b/modules/gdpr_tasks/config/install/core.entity_form_display.gdpr_task.gdpr_remove.default.yml index 207579d..e4072ab 100644 --- a/modules/gdpr_tasks/config/install/core.entity_form_display.gdpr_task.gdpr_remove.default.yml +++ b/modules/gdpr_tasks/config/install/core.entity_form_display.gdpr_task.gdpr_remove.default.yml @@ -2,6 +2,7 @@ langcode: en status: true dependencies: config: + - field.field.gdpr_task.gdpr_remove.removal_log - gdpr_tasks.gdpr_task_type.gdpr_remove third_party_settings: { } id: gdpr_task.gdpr_remove.default diff --git a/modules/gdpr_tasks/config/install/core.entity_view_display.gdpr_task.gdpr_remove.default.yml b/modules/gdpr_tasks/config/install/core.entity_view_display.gdpr_task.gdpr_remove.default.yml index b797ef9..878976a 100644 --- a/modules/gdpr_tasks/config/install/core.entity_view_display.gdpr_task.gdpr_remove.default.yml +++ b/modules/gdpr_tasks/config/install/core.entity_view_display.gdpr_task.gdpr_remove.default.yml @@ -2,8 +2,10 @@ langcode: en status: true dependencies: config: + - field.field.gdpr_task.gdpr_remove.removal_log - gdpr_tasks.gdpr_task_type.gdpr_remove module: + - gdpr_tasks - options - user id: gdpr_task.gdpr_remove.default @@ -11,6 +13,20 @@ targetEntityType: gdpr_task bundle: gdpr_remove mode: default content: + processed_by: + label: inline + type: author + weight: 2 + region: content + settings: { } + third_party_settings: { } + removal_log: + type: gdpr_task_item + weight: 3 + region: content + label: above + settings: { } + third_party_settings: { } status: type: list_default weight: 1 diff --git a/modules/gdpr_tasks/config/install/core.entity_view_display.gdpr_task.gdpr_sar.default.yml b/modules/gdpr_tasks/config/install/core.entity_view_display.gdpr_task.gdpr_sar.default.yml index 2c834b4..77dec62 100644 --- a/modules/gdpr_tasks/config/install/core.entity_view_display.gdpr_task.gdpr_sar.default.yml +++ b/modules/gdpr_tasks/config/install/core.entity_view_display.gdpr_task.gdpr_sar.default.yml @@ -36,5 +36,12 @@ content: region: content settings: { } third_party_settings: { } + processed_by: + type: author + weight: 5 + region: content + label: inline + settings: { } + third_party_settings: { } hidden: manual_data: true diff --git a/modules/gdpr_tasks/config/install/field.field.gdpr_task.gdpr_remove.removal_log.yml b/modules/gdpr_tasks/config/install/field.field.gdpr_task.gdpr_remove.removal_log.yml new file mode 100644 index 0000000..d03d8d4 --- /dev/null +++ b/modules/gdpr_tasks/config/install/field.field.gdpr_task.gdpr_remove.removal_log.yml @@ -0,0 +1,20 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.gdpr_task.removal_log + - gdpr_tasks.gdpr_task_type.gdpr_remove + module: + - gdpr_tasks +id: gdpr_task.gdpr_remove.removal_log +field_name: removal_log +entity_type: gdpr_task +bundle: gdpr_remove +label: 'Removal Log' +description: '' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: { } +field_type: gdpr_task_item diff --git a/modules/gdpr_tasks/config/install/field.storage.gdpr_task.removal_log.yml b/modules/gdpr_tasks/config/install/field.storage.gdpr_task.removal_log.yml new file mode 100644 index 0000000..717ba37 --- /dev/null +++ b/modules/gdpr_tasks/config/install/field.storage.gdpr_task.removal_log.yml @@ -0,0 +1,17 @@ +langcode: en +status: true +dependencies: + module: + - gdpr_tasks +id: gdpr_task.removal_log +field_name: removal_log +entity_type: gdpr_task +type: gdpr_task_item +settings: { } +module: gdpr_tasks +locked: false +cardinality: -1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/modules/gdpr_tasks/src/Anonymizer.php b/modules/gdpr_tasks/src/Anonymizer.php index f1a0499..42ab6f2 100644 --- a/modules/gdpr_tasks/src/Anonymizer.php +++ b/modules/gdpr_tasks/src/Anonymizer.php @@ -45,13 +45,15 @@ public function __construct(GDPRCollector $collector, Connection $db, EntityType public function run(TaskInterface $task) { // Make sure we load a fresh copy of the entity (bypassing the cache) // so we don't end up affecting any other references to the entity. - $user = $task->getOwner(); //$this->refetchUser($task->getOwnerId()); + $user = $task->getOwner(); $errors = []; $entities = []; $successes = []; $failures = []; + $log = []; + $this->collector->getValueEntities($entities, 'user', $user); foreach ($entities as $entity_type => $bundles) { @@ -78,7 +80,15 @@ public function run(TaskInterface $task) { list($success, $msg) = $this->remove($field); } - if ($success === FALSE) { + if ($success === TRUE) { + $log[] = [ + 'entity_id' => $bundle_entity->id(), + 'entity_type' => $bundle_entity->getEntityTypeId() . '.' . $bundle_entity->bundle(), + 'field_name' => $field->getName(), + 'action' => $mode, + ]; + } + else { // Could not anonymize/remove field. Record to errors list. // Prevent entity from being saved. $entity_success = FALSE; @@ -95,6 +105,8 @@ public function run(TaskInterface $task) { } } + $task->get('removal_log')->setValue($log); + if (count($failures) === 0) { $tx = $this->db->startTransaction(); diff --git a/modules/gdpr_tasks/src/Entity/Task.php b/modules/gdpr_tasks/src/Entity/Task.php index 3fa213d..d554e44 100644 --- a/modules/gdpr_tasks/src/Entity/Task.php +++ b/modules/gdpr_tasks/src/Entity/Task.php @@ -224,9 +224,8 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { ->setSetting('handler', 'default') ->setTranslatable(TRUE) ->setDisplayOptions('view', [ - 'label' => 'hidden', 'type' => 'author', - 'weight' => 0, + 'weight' => 5, ]) ->setDisplayOptions('form', [ 'type' => 'hidden', diff --git a/modules/gdpr_tasks/src/Plugin/Field/FieldFormatter/TaskLogItemFormatter.php b/modules/gdpr_tasks/src/Plugin/Field/FieldFormatter/TaskLogItemFormatter.php new file mode 100644 index 0000000..a10fc37 --- /dev/null +++ b/modules/gdpr_tasks/src/Plugin/Field/FieldFormatter/TaskLogItemFormatter.php @@ -0,0 +1,37 @@ + $item) { + $elements[$key] = [ + '#type' => 'markup', + '#markup' => "Entity type: {$item->entity_type} ID: {$item->entity_id} Field name: {$item->field_name} Action: {$item->action}", + ]; + } + + return $elements; + } + +} diff --git a/modules/gdpr_tasks/src/Plugin/Field/FieldType/TaskLogItem.php b/modules/gdpr_tasks/src/Plugin/Field/FieldType/TaskLogItem.php new file mode 100644 index 0000000..c3902f0 --- /dev/null +++ b/modules/gdpr_tasks/src/Plugin/Field/FieldType/TaskLogItem.php @@ -0,0 +1,65 @@ +setLabel('Entity ID'); + + $properties['entity_type'] = DataDefinition::create('string') + ->setLabel('Entity Type'); + + $properties['field_name'] = DataDefinition::create('string') + ->setLabel('Field Name'); + + $properties['action'] = DataDefinition::create('string') + ->setLabel('Action'); + + return $properties; + } + + /** + * {@inheritdoc} + */ + public static function schema(FieldStorageDefinitionInterface $field_definition) { + return [ + 'columns' => [ + 'entity_id' => [ + 'type' => 'int', + ], + 'entity_type' => [ + 'type' => 'varchar', + 'length' => 255, + ], + 'field_name' => [ + 'type' => 'varchar', + 'length' => 255, + ], + 'action' => [ + 'type' => 'varchar', + 'length' => 20, + ], + ], + ]; + } +} \ No newline at end of file diff --git a/modules/gdpr_tasks/src/Plugin/Field/FieldWidget/TaskLogItemWidget.php b/modules/gdpr_tasks/src/Plugin/Field/FieldWidget/TaskLogItemWidget.php new file mode 100644 index 0000000..ad2437a --- /dev/null +++ b/modules/gdpr_tasks/src/Plugin/Field/FieldWidget/TaskLogItemWidget.php @@ -0,0 +1,51 @@ + 'number', + '#title' => 'Entity ID', + ]; + + $element['entity_type'] = [ + '#type' => 'textfield', + '#title' => 'Entity type', + ]; + + $element['field_name'] = [ + '#type' => 'textfield', + '#title' => 'Field Name', + ]; + + $element['action'] = [ + '#type' => 'textfield', + '#title' => 'Action', + ]; + + return $element; + } +} \ No newline at end of file From 906259e2caec093e1f2f9d3745402294b3bd7135 Mon Sep 17 00:00:00 2001 From: Jeremy Skinner Date: Tue, 13 Mar 2018 14:11:51 +0000 Subject: [PATCH 09/16] Switch to using the existing sanitizer infrasturcture. Pass field definition to the sanitizer for metadata. --- .../Plugin/Gdpr/Sanitizer/DateSanitizer.php | 29 ++++++ .../Plugin/Gdpr/Sanitizer/EmailSanitizer.php | 3 +- .../Gdpr/Sanitizer/LongTextSanitizer.php | 3 +- .../Gdpr/Sanitizer/PasswordSanitizer.php | 3 +- .../Gdpr/Sanitizer/RandomTextSanitizer.php | 55 ++++++++++++ .../Plugin/Gdpr/Sanitizer/TextSanitizer.php | 3 +- .../Gdpr/Sanitizer/UsernameSanitizer.php | 3 +- .../src/Sanitizer/GdprSanitizerFactory.php | 8 ++ .../src/Sanitizer/GdprSanitizerInterface.php | 4 +- .../Sanitizer/GdprSanitizerPluginManager.php | 2 - modules/gdpr_fields/gdpr_fields.module | 46 +++++++--- modules/gdpr_tasks/gdpr_tasks.module | 2 + modules/gdpr_tasks/gdpr_tasks.services.yml | 2 +- modules/gdpr_tasks/src/Anonymizer.php | 88 +++++++++---------- 14 files changed, 185 insertions(+), 66 deletions(-) create mode 100644 modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/DateSanitizer.php create mode 100644 modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/RandomTextSanitizer.php diff --git a/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/DateSanitizer.php b/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/DateSanitizer.php new file mode 100644 index 0000000..9b7d2e6 --- /dev/null +++ b/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/DateSanitizer.php @@ -0,0 +1,29 @@ +random) { $this->random = new Random(); } diff --git a/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/RandomTextSanitizer.php b/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/RandomTextSanitizer.php new file mode 100644 index 0000000..8b7fb3d --- /dev/null +++ b/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/RandomTextSanitizer.php @@ -0,0 +1,55 @@ +getDataDefinition()->getSetting("max_length"); + + $value = ''; + + if (!empty($input)) { + // Generate a prefixed random string. + $value = "anon_" . $this->generateRandomString(4); + // If the value is too long, tirm it. + if (isset($max_length) && strlen($input) > $max_length) { + $value = substr(0, $max_length); + } + } + return $value; + } + + /** + * Generates a random string of the specified length. + */ + private function generateRandomString($length) { + $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $charactersLength = strlen($characters); + $randomString = ''; + for ($i = 0; $i < $length; $i++) { + $randomString .= $characters[rand(0, $charactersLength - 1)]; + } + return $randomString; + } + +} diff --git a/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/TextSanitizer.php b/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/TextSanitizer.php index c013332..41040c0 100644 --- a/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/TextSanitizer.php +++ b/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/TextSanitizer.php @@ -3,6 +3,7 @@ namespace Drupal\gdpr_dump\Plugin\Gdpr\Sanitizer; use Drupal\Component\Utility\Random; +use Drupal\Core\Field\FieldItemListInterface; use Drupal\gdpr_dump\Sanitizer\GdprSanitizerBase; /** @@ -30,7 +31,7 @@ class TextSanitizer extends GdprSanitizerBase { * * @throws \RuntimeException */ - public function sanitize($input) { + public function sanitize($input, FieldItemListInterface $field) { if (empty($input)) { return $input; } diff --git a/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/UsernameSanitizer.php b/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/UsernameSanitizer.php index 8fa2c41..5621ced 100644 --- a/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/UsernameSanitizer.php +++ b/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/UsernameSanitizer.php @@ -3,6 +3,7 @@ namespace Drupal\gdpr_dump\Plugin\Gdpr\Sanitizer; use Drupal\Component\Utility\Random; +use Drupal\Core\Field\FieldItemListInterface; use Drupal\gdpr_dump\Sanitizer\GdprSanitizerBase; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -54,7 +55,7 @@ public static function create(ContainerInterface $container, array $configuratio * * @throws \RuntimeException */ - public function sanitize($input) { + public function sanitize($input, FieldItemListInterface $field) { if (empty($input)) { return $input; } diff --git a/modules/gdpr_dump/src/Sanitizer/GdprSanitizerFactory.php b/modules/gdpr_dump/src/Sanitizer/GdprSanitizerFactory.php index 04cdbb4..884dab3 100644 --- a/modules/gdpr_dump/src/Sanitizer/GdprSanitizerFactory.php +++ b/modules/gdpr_dump/src/Sanitizer/GdprSanitizerFactory.php @@ -54,4 +54,12 @@ public function get($name) { return $this->sanitizers[$name]; } + + /** + * Gets all sanitizers currently registered. + */ + public function getDefinitions() { + return $this->pluginManager->getDefinitions(); + } + } diff --git a/modules/gdpr_dump/src/Sanitizer/GdprSanitizerInterface.php b/modules/gdpr_dump/src/Sanitizer/GdprSanitizerInterface.php index 8dea85e..3f23277 100644 --- a/modules/gdpr_dump/src/Sanitizer/GdprSanitizerInterface.php +++ b/modules/gdpr_dump/src/Sanitizer/GdprSanitizerInterface.php @@ -2,6 +2,8 @@ namespace Drupal\gdpr_dump\Sanitizer; +use Drupal\Core\Field\FieldItemListInterface; + /** * Interface GdprSanitizerInterface. * @@ -18,6 +20,6 @@ interface GdprSanitizerInterface { * @return int|string * The sanitized input. */ - public function sanitize($input); + public function sanitize($input, FieldItemListInterface $field); } diff --git a/modules/gdpr_dump/src/Sanitizer/GdprSanitizerPluginManager.php b/modules/gdpr_dump/src/Sanitizer/GdprSanitizerPluginManager.php index 76ba324..f96f5af 100644 --- a/modules/gdpr_dump/src/Sanitizer/GdprSanitizerPluginManager.php +++ b/modules/gdpr_dump/src/Sanitizer/GdprSanitizerPluginManager.php @@ -45,8 +45,6 @@ public function __construct( $this->setCacheBackend($cacheBackend, 'gdpr_sanitizer_plugins'); $this->alterInfo('gdpr_sanitizer_info'); - - } } diff --git a/modules/gdpr_fields/gdpr_fields.module b/modules/gdpr_fields/gdpr_fields.module index 1f084d4..99fc75e 100644 --- a/modules/gdpr_fields/gdpr_fields.module +++ b/modules/gdpr_fields/gdpr_fields.module @@ -14,6 +14,10 @@ use Drupal\Core\Form\FormStateInterface; * @todo Check user edit permission for GDPR fields. */ function gdpr_fields_form_field_config_edit_form_alter(&$form, FormStateInterface $form_state, $form_id) { + + /* @var \Drupal\gdpr_dump\Sanitizer\GdprSanitizerFactory $sanitizer_factory */ + $sanitizer_factory = \Drupal::service('gdpr_dump.sanitizer_factory'); + $sanitizer_definitions = $sanitizer_factory->getDefinitions(); /* @var \Drupal\Core\Field\FieldConfigInterface $field */ $field = $form_state->getFormObject()->getEntity(); // @todo Check that target entity is a content entity. @@ -39,13 +43,13 @@ function gdpr_fields_form_field_config_edit_form_alter(&$form, FormStateInterfac 'no' => 'Not', ], '#default_value' => $field->getThirdPartySetting('gdpr_fields', 'gdpr_fields_rta', 'no'), - '#states' => array( - 'visible' => array( - ':input[name="gdpr_fields_enabled"]' => array( + '#states' => [ + 'visible' => [ + ':input[name="gdpr_fields_enabled"]' => [ 'checked' => TRUE, - ), - ), - ), + ], + ], + ], ]; $form['field']['gdpr_fields']['gdpr_fields_rtf'] = [ @@ -58,14 +62,32 @@ function gdpr_fields_form_field_config_edit_form_alter(&$form, FormStateInterfac 'no' => 'Not', ], '#default_value' => $field->getThirdPartySetting('gdpr_fields', 'gdpr_fields_rtf', 'no'), - '#states' => array( - 'visible' => array( - ':input[name="gdpr_fields_enabled"]' => array( + '#states' => [ + 'visible' => [ + ':input[name="gdpr_fields_enabled"]' => [ 'checked' => TRUE, - ), - ), - ), + ], + ], + ], + ]; + + $sanitizer_options = ['' => ''] + array_map(function ($s) { + return $s['label']; + }, $sanitizer_definitions); + + $form['field']['gdpr_fields']['gdpr_fields_sanitizer'] = [ + '#type' => 'select', + '#title' => t('Anonymizer to use'), + '#options' => $sanitizer_options, + '#default_value' => $field->getThirdPartySetting('gdpr_fields', 'gdpr_fields_sanitizer', ''), + '#states' => [ + 'visible' => [ + ':input[name="gdpr_fields_enabled"]' => ['checked' => TRUE], + ':input[name="gdpr_fields_rtf"]' => ['value' => 'anonymise'], + ], + ], ]; + $form['actions']['submit']['#submit'][] = 'gdpr_fields_form_field_config_edit_form_submit'; } diff --git a/modules/gdpr_tasks/gdpr_tasks.module b/modules/gdpr_tasks/gdpr_tasks.module index 4065e89..ba65b18 100644 --- a/modules/gdpr_tasks/gdpr_tasks.module +++ b/modules/gdpr_tasks/gdpr_tasks.module @@ -205,10 +205,12 @@ function gdpr_tasks_entity_base_field_info_alter(&$fields, \Drupal\Core\Entity\E $fields['mail']['third_party_settings']['gdpr_fields']['gdpr_fields_enabled'] = TRUE; $fields['mail']['third_party_settings']['gdpr_fields']['gdpr_fields_rta'] = 'inc'; $fields['mail']['third_party_settings']['gdpr_fields']['gdpr_fields_rtf'] = 'anonymise'; + $fields['mail']['third_party_settings']['gdpr_fields']['gdpr_fields_sanitizer'] = 'gpdr_email_sanitizer'; $fields['name']['third_party_settings']['gdpr_fields']['gdpr_fields_enabled'] = TRUE; $fields['name']['third_party_settings']['gdpr_fields']['gdpr_fields_rta'] = 'inc'; $fields['name']['third_party_settings']['gdpr_fields']['gdpr_fields_rtf'] = 'anonymise'; + $fields['name']['third_party_settings']['gdpr_fields']['gdpr_fields_sanitizer'] = 'gpdr_random_text_sanitizer'; $fields['roles']['third_party_settings']['gdpr_fields']['gdpr_fields_enabled'] = TRUE; $fields['roles']['third_party_settings']['gdpr_fields']['gdpr_fields_rta'] = 'no'; diff --git a/modules/gdpr_tasks/gdpr_tasks.services.yml b/modules/gdpr_tasks/gdpr_tasks.services.yml index 1c09777..02df2b1 100644 --- a/modules/gdpr_tasks/gdpr_tasks.services.yml +++ b/modules/gdpr_tasks/gdpr_tasks.services.yml @@ -4,4 +4,4 @@ services: arguments: ['@entity_type.manager', '@current_user'] gdpr_tasks.anonymizer: class: Drupal\gdpr_tasks\Anonymizer - arguments: ['@gdpr_fields.collector', '@database', '@entity_type.manager', '@module_handler', '@current_user'] \ No newline at end of file + arguments: ['@gdpr_fields.collector', '@database', '@entity_type.manager', '@module_handler', '@current_user', '@gdpr_dump.sanitizer_factory'] \ No newline at end of file diff --git a/modules/gdpr_tasks/src/Anonymizer.php b/modules/gdpr_tasks/src/Anonymizer.php index 42ab6f2..6e9a805 100644 --- a/modules/gdpr_tasks/src/Anonymizer.php +++ b/modules/gdpr_tasks/src/Anonymizer.php @@ -2,6 +2,8 @@ namespace Drupal\gdpr_tasks; +use Drupal\Component\Annotation\Plugin; +use Drupal\Component\Plugin\Exception\PluginException; use Drupal\Core\Database\Connection; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; @@ -9,6 +11,7 @@ use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Session\AccountProxyInterface; use Drupal\Core\TypedData\Exception\ReadOnlyException; +use Drupal\gdpr_dump\Sanitizer\GdprSanitizerFactory; use Drupal\gdpr_fields\GDPRCollector; use Drupal\gdpr_tasks\Entity\TaskInterface; use Drupal\user\Entity\User; @@ -28,15 +31,18 @@ class Anonymizer { private $currentUser; + private $sanitizerFactory; + /** * Anonymizer constructor. */ - public function __construct(GDPRCollector $collector, Connection $db, EntityTypeManagerInterface $entity_manager, ModuleHandlerInterface $module_handler, AccountProxyInterface $current_user) { + public function __construct(GDPRCollector $collector, Connection $db, EntityTypeManagerInterface $entity_manager, ModuleHandlerInterface $module_handler, AccountProxyInterface $current_user, GdprSanitizerFactory $sanitizer_factory) { $this->collector = $collector; $this->db = $db; $this->entityTypeManager = $entity_manager; $this->moduleHandler = $module_handler; $this->currentUser = $current_user; + $this->sanitizerFactory = $sanitizer_factory; } /** @@ -142,57 +148,49 @@ private function remove(FieldItemListInterface $field) { } } - /** - * Anonymizes a field. - * - * Uses the GetAnonymizersEvent to find an appropriate anonymization function. - */ private function anonymize(FieldItemListInterface $field, EntityInterface $bundle_entity, $entity_type) { - // For string fields, generate a random string the same length. - $type = $field->getFieldDefinition()->getType(); - $field_key = implode('.', array_filter( - [$entity_type, $bundle_entity->bundle(), $field->getName()])); - - $anonymizers = [ - 'field' => [ - 'user.user.mail' => ['\Drupal\gdpr_tasks\Anonymizer', 'anonymizeMail'], - ], - 'type' => [ - 'string' => ['\Drupal\gdpr_tasks\Anonymizer', 'anonymizeString'], - 'datetime' => ['\Drupal\gdpr_tasks\Anonymizer', 'anonymizeDate'], - ], - ]; - - $this->moduleHandler->alter('gdpr_anonymizers', $anonymizers); - - // Check if there's an anonymizer for this field. - if (isset($anonymizers['field'][$field_key]) && is_callable($anonymizers['field'][$field_key])) { - try { - call_user_func($anonymizers['field'][$field_key], $field); - return [TRUE, NULL]; - } - catch (\Exception $e) { - return [FALSE, $e->getMessage()]; - } - } - // If not, anonymize by type instead. - elseif (isset($anonymizers['type'][$type]) && is_callable($anonymizers['type'][$type])) { - try { - call_user_func($anonymizers['type'][$type], $field); - return [TRUE, NULL]; - } - catch (\Exception $e) { - return [FALSE, $e->getMessage()]; - } - } - else { + $sanitizer_id = $this->getSanitizerId($field, $bundle_entity); + + if (!$sanitizer_id) { return [ FALSE, - "Could not anonymize field {$field->getName()}. Please consider changing this field from 'anonymize' to 'remove', or register a custom anonymizer function for the type {$type}.", + "Could not anonymize field {$field->getName()}. Please consider changing this field from 'anonymize' to 'remove', or register a custom sanitizer.", + ]; + } + + try { + $sanitizer = $this->sanitizerFactory->get($sanitizer_id); + $field->setValue($sanitizer->sanitize($field->value, $field)); + return [TRUE, NULL]; + } + catch (\Exception $e) { + return [FALSE, $e->getMessage()]; + } + + } + + private function getSanitizerId(FieldItemListInterface $field, EntityInterface $bundle_entity) { + // First check if this field has a sanitizer defined. + $fieldDefinition = $field->getFieldDefinition(); + $type = $fieldDefinition->getType(); + $sanitizer = $fieldDefinition + ->getConfig($bundle_entity->bundle()) + ->getThirdPartySetting('gdpr_fields', 'gdpr_fields_sanitizer'); + + if (!$sanitizer) { + // No sanitizer defined directly on the field. Instead try and get one for the datatype. + $sanitizers = [ + 'string' => 'gpdr_text_sanitizer', + 'datetime' => 'gdpr_date_sanitizer', ]; + + $this->moduleHandler->alter('gdpr_type_sanitizers', $sanitizers); + $sanitizer = $sanitizers[$type]; } + return $sanitizer; } + /** * Gets fields to anonymize/remove. */ From aa30efa6bb34a76811c4b245b25e0331f0bd4fd2 Mon Sep 17 00:00:00 2001 From: Jeremy Skinner Date: Tue, 13 Mar 2018 14:32:13 +0000 Subject: [PATCH 10/16] Fix typos in GdprSanitizer ID definitions --- modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/DateSanitizer.php | 2 +- .../gdpr_dump/src/Plugin/Gdpr/Sanitizer/EmailSanitizer.php | 2 +- .../gdpr_dump/src/Plugin/Gdpr/Sanitizer/LongTextSanitizer.php | 2 +- .../gdpr_dump/src/Plugin/Gdpr/Sanitizer/PasswordSanitizer.php | 2 +- .../src/Plugin/Gdpr/Sanitizer/RandomTextSanitizer.php | 2 +- modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/TextSanitizer.php | 2 +- .../gdpr_dump/src/Plugin/Gdpr/Sanitizer/UsernameSanitizer.php | 2 +- modules/gdpr_fields/gdpr_fields.module | 3 +++ modules/gdpr_tasks/gdpr_tasks.module | 4 ++-- modules/gdpr_tasks/src/Anonymizer.php | 2 +- 10 files changed, 13 insertions(+), 10 deletions(-) diff --git a/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/DateSanitizer.php b/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/DateSanitizer.php index 9b7d2e6..36b2065 100644 --- a/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/DateSanitizer.php +++ b/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/DateSanitizer.php @@ -10,7 +10,7 @@ * Class TextSanitizer. * * @GdprSanitizer( - * id = "gpdr_date_sanitizer", + * id = "gdpr_date_sanitizer", * label = @Translation("Date sanitizer"), * description=@Translation("Provides sanitation functionality intended to be used for datetime fields.") * ) diff --git a/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/EmailSanitizer.php b/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/EmailSanitizer.php index baebc77..99dde4d 100644 --- a/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/EmailSanitizer.php +++ b/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/EmailSanitizer.php @@ -11,7 +11,7 @@ * Class EmailSanitizer. * * @GdprSanitizer( - * id = "gpdr_email_sanitizer", + * id = "gdpr_email_sanitizer", * label = @Translation("Email sanitizer"), * description=@Translation("Provides sanitation functionality intended to be used for emails.") * ) diff --git a/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/LongTextSanitizer.php b/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/LongTextSanitizer.php index b053fbc..a58e46b 100644 --- a/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/LongTextSanitizer.php +++ b/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/LongTextSanitizer.php @@ -10,7 +10,7 @@ * Class LongTextSanitizer. * * @GdprSanitizer( - * id = "gpdr_long_text_sanitizer", + * id = "gdpr_long_text_sanitizer", * label = @Translation("Long text sanitizer"), * description=@Translation("Provides sanitation functionality intended to be used for longer text.") * ) diff --git a/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/PasswordSanitizer.php b/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/PasswordSanitizer.php index c2aa2fb..07317d4 100644 --- a/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/PasswordSanitizer.php +++ b/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/PasswordSanitizer.php @@ -11,7 +11,7 @@ * Class PasswordSanitizer. * * @GdprSanitizer( - * id = "gpdr_password_sanitizer", + * id = "gdpr_password_sanitizer", * label = @Translation("Password sanitizer"), * description=@Translation("Provides sanitation functionality intended to be used for passwords.") * ) diff --git a/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/RandomTextSanitizer.php b/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/RandomTextSanitizer.php index 8b7fb3d..504f5e6 100644 --- a/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/RandomTextSanitizer.php +++ b/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/RandomTextSanitizer.php @@ -10,7 +10,7 @@ * Class TextSanitizer. * * @GdprSanitizer( - * id = "gpdr_random_text_sanitizer", + * id = "gdpr_random_text_sanitizer", * label = @Translation("Random Text sanitizer"), * description=@Translation("Provides sanitation functionality intended to be * used for text fields.") diff --git a/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/TextSanitizer.php b/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/TextSanitizer.php index 41040c0..a00e3db 100644 --- a/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/TextSanitizer.php +++ b/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/TextSanitizer.php @@ -10,7 +10,7 @@ * Class TextSanitizer. * * @GdprSanitizer( - * id = "gpdr_text_sanitizer", + * id = "gdpr_text_sanitizer", * label = @Translation("Text sanitizer"), * description=@Translation("Provides sanitation functionality intended to be used for titles or short text.") * ) diff --git a/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/UsernameSanitizer.php b/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/UsernameSanitizer.php index 5621ced..aa92924 100644 --- a/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/UsernameSanitizer.php +++ b/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/UsernameSanitizer.php @@ -11,7 +11,7 @@ * Class UsernameSanitizer. * * @GdprSanitizer( - * id = "gpdr_username_sanitizer", + * id = "gdpr_username_sanitizer", * label = @Translation("Username sanitizer"), * description=@Translation("Provides sanitation functionality intended to be used for usernames.") * ) diff --git a/modules/gdpr_fields/gdpr_fields.module b/modules/gdpr_fields/gdpr_fields.module index 99fc75e..5f4ced1 100644 --- a/modules/gdpr_fields/gdpr_fields.module +++ b/modules/gdpr_fields/gdpr_fields.module @@ -109,12 +109,15 @@ function gdpr_fields_form_field_config_edit_form_submit(array $form, FormStateIn $field->setThirdPartySetting('gdpr_fields', 'gdpr_fields_enabled', TRUE); $field->setThirdPartySetting('gdpr_fields', 'gdpr_fields_rta', $form_fields['gdpr_fields_rta']); $field->setThirdPartySetting('gdpr_fields', 'gdpr_fields_rtf', $form_fields['gdpr_fields_rtf']); + $field->setThirdPartySetting('gdpr_fields', 'gdpr_fields_sanitizer', $form_fields['gdpr_fields_sanitizer']); $field->save(); } else { $field->unsetThirdPartySetting('gdpr_fields', 'gdpr_fields_enabled'); $field->unsetThirdPartySetting('gdpr_fields', 'gdpr_fields_rta'); $field->unsetThirdPartySetting('gdpr_fields', 'gdpr_fields_rtf'); + $field->unsetThirdPartySetting('gdpr_fields', 'gdpr_fields_sanitizer'); + $field->save(); } diff --git a/modules/gdpr_tasks/gdpr_tasks.module b/modules/gdpr_tasks/gdpr_tasks.module index ba65b18..4bd176f 100644 --- a/modules/gdpr_tasks/gdpr_tasks.module +++ b/modules/gdpr_tasks/gdpr_tasks.module @@ -205,12 +205,12 @@ function gdpr_tasks_entity_base_field_info_alter(&$fields, \Drupal\Core\Entity\E $fields['mail']['third_party_settings']['gdpr_fields']['gdpr_fields_enabled'] = TRUE; $fields['mail']['third_party_settings']['gdpr_fields']['gdpr_fields_rta'] = 'inc'; $fields['mail']['third_party_settings']['gdpr_fields']['gdpr_fields_rtf'] = 'anonymise'; - $fields['mail']['third_party_settings']['gdpr_fields']['gdpr_fields_sanitizer'] = 'gpdr_email_sanitizer'; + $fields['mail']['third_party_settings']['gdpr_fields']['gdpr_fields_sanitizer'] = 'gdpr_email_sanitizer'; $fields['name']['third_party_settings']['gdpr_fields']['gdpr_fields_enabled'] = TRUE; $fields['name']['third_party_settings']['gdpr_fields']['gdpr_fields_rta'] = 'inc'; $fields['name']['third_party_settings']['gdpr_fields']['gdpr_fields_rtf'] = 'anonymise'; - $fields['name']['third_party_settings']['gdpr_fields']['gdpr_fields_sanitizer'] = 'gpdr_random_text_sanitizer'; + $fields['name']['third_party_settings']['gdpr_fields']['gdpr_fields_sanitizer'] = 'gdpr_random_text_sanitizer'; $fields['roles']['third_party_settings']['gdpr_fields']['gdpr_fields_enabled'] = TRUE; $fields['roles']['third_party_settings']['gdpr_fields']['gdpr_fields_rta'] = 'no'; diff --git a/modules/gdpr_tasks/src/Anonymizer.php b/modules/gdpr_tasks/src/Anonymizer.php index 6e9a805..8c76fbc 100644 --- a/modules/gdpr_tasks/src/Anonymizer.php +++ b/modules/gdpr_tasks/src/Anonymizer.php @@ -180,7 +180,7 @@ private function getSanitizerId(FieldItemListInterface $field, EntityInterface $ if (!$sanitizer) { // No sanitizer defined directly on the field. Instead try and get one for the datatype. $sanitizers = [ - 'string' => 'gpdr_text_sanitizer', + 'string' => 'gdpr_text_sanitizer', 'datetime' => 'gdpr_date_sanitizer', ]; From 8a77cad40f5475083817155742e289e18c04d20f Mon Sep 17 00:00:00 2001 From: Jeremy Skinner Date: Tue, 13 Mar 2018 14:42:15 +0000 Subject: [PATCH 11/16] Update field label for sanitizer --- modules/gdpr_fields/gdpr_fields.module | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/gdpr_fields/gdpr_fields.module b/modules/gdpr_fields/gdpr_fields.module index 5f4ced1..392ab51 100644 --- a/modules/gdpr_fields/gdpr_fields.module +++ b/modules/gdpr_fields/gdpr_fields.module @@ -77,7 +77,7 @@ function gdpr_fields_form_field_config_edit_form_alter(&$form, FormStateInterfac $form['field']['gdpr_fields']['gdpr_fields_sanitizer'] = [ '#type' => 'select', - '#title' => t('Anonymizer to use'), + '#title' => t('Sanitizer to use'), '#options' => $sanitizer_options, '#default_value' => $field->getThirdPartySetting('gdpr_fields', 'gdpr_fields_sanitizer', ''), '#states' => [ From dba2706c1d01387368a1101add23812e3971914b Mon Sep 17 00:00:00 2001 From: Jeremy Skinner Date: Tue, 13 Mar 2018 15:06:56 +0000 Subject: [PATCH 12/16] Add log field for sanitizer. Fix call to sanitize from dump module. --- .../src/Plugin/Gdpr/Sanitizer/RandomTextSanitizer.php | 7 ++++++- modules/gdpr_dump/src/Service/GdprSqlDump.php | 2 +- .../Plugin/Field/FieldFormatter/TaskLogItemFormatter.php | 2 +- .../gdpr_tasks/src/Plugin/Field/FieldType/TaskLogItem.php | 7 +++++++ .../src/Plugin/Field/FieldWidget/TaskLogItemWidget.php | 5 +++++ 5 files changed, 20 insertions(+), 3 deletions(-) diff --git a/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/RandomTextSanitizer.php b/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/RandomTextSanitizer.php index 504f5e6..cf2a89f 100644 --- a/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/RandomTextSanitizer.php +++ b/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/RandomTextSanitizer.php @@ -24,7 +24,12 @@ class RandomTextSanitizer extends GdprSanitizerBase { * {@inheritdoc} */ public function sanitize($input, FieldItemListInterface $field) { - $max_length = $field->getDataDefinition()->getSetting("max_length"); + if ($field != NULL) { + $max_length = $field->getDataDefinition()->getSetting("max_length"); + } + else { + $max_length = NULL; + } $value = ''; diff --git a/modules/gdpr_dump/src/Service/GdprSqlDump.php b/modules/gdpr_dump/src/Service/GdprSqlDump.php index 425095b..6b5cbde 100644 --- a/modules/gdpr_dump/src/Service/GdprSqlDump.php +++ b/modules/gdpr_dump/src/Service/GdprSqlDump.php @@ -294,7 +294,7 @@ protected function sanitizeData() { * Also add a way to make exceptions * e.g option for 'don't alter uid 1 name', etc. */ - $row[$column] = $this->pluginFactory->get($pluginId)->sanitize($row[$column]); + $row[$column] = $this->pluginFactory->get($pluginId)->sanitize($row[$column], NULL); } $insertQuery->values($row); } diff --git a/modules/gdpr_tasks/src/Plugin/Field/FieldFormatter/TaskLogItemFormatter.php b/modules/gdpr_tasks/src/Plugin/Field/FieldFormatter/TaskLogItemFormatter.php index a10fc37..bdaeb6c 100644 --- a/modules/gdpr_tasks/src/Plugin/Field/FieldFormatter/TaskLogItemFormatter.php +++ b/modules/gdpr_tasks/src/Plugin/Field/FieldFormatter/TaskLogItemFormatter.php @@ -27,7 +27,7 @@ public function viewElements(FieldItemListInterface $items, $langcode) { foreach ($items as $key => $item) { $elements[$key] = [ '#type' => 'markup', - '#markup' => "Entity type: {$item->entity_type} ID: {$item->entity_id} Field name: {$item->field_name} Action: {$item->action}", + '#markup' => "Entity type: {$item->entity_type} ID: {$item->entity_id} Field name: {$item->field_name} Action: {$item->action} Sanitizer: {$item->sanitizer}", ]; } diff --git a/modules/gdpr_tasks/src/Plugin/Field/FieldType/TaskLogItem.php b/modules/gdpr_tasks/src/Plugin/Field/FieldType/TaskLogItem.php index c3902f0..1eda0a0 100644 --- a/modules/gdpr_tasks/src/Plugin/Field/FieldType/TaskLogItem.php +++ b/modules/gdpr_tasks/src/Plugin/Field/FieldType/TaskLogItem.php @@ -35,6 +35,9 @@ public static function propertyDefinitions(FieldStorageDefinitionInterface $fiel $properties['action'] = DataDefinition::create('string') ->setLabel('Action'); + $properties['sanitizer'] = DataDefinition::create('string') + ->setLabel('Sanitizer'); + return $properties; } @@ -59,6 +62,10 @@ public static function schema(FieldStorageDefinitionInterface $field_definition) 'type' => 'varchar', 'length' => 20, ], + 'sanitizer' => [ + 'type' => 'varchar', + 'length' => 255, + ] ], ]; } diff --git a/modules/gdpr_tasks/src/Plugin/Field/FieldWidget/TaskLogItemWidget.php b/modules/gdpr_tasks/src/Plugin/Field/FieldWidget/TaskLogItemWidget.php index ad2437a..de3430f 100644 --- a/modules/gdpr_tasks/src/Plugin/Field/FieldWidget/TaskLogItemWidget.php +++ b/modules/gdpr_tasks/src/Plugin/Field/FieldWidget/TaskLogItemWidget.php @@ -46,6 +46,11 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen '#title' => 'Action', ]; + $element['sanitizer'] = [ + '#type' => 'textfield', + '#title' => 'Sanitizer', + ]; + return $element; } } \ No newline at end of file From 4a27249f4680ccc1f16f2460e1e1651aba56acd8 Mon Sep 17 00:00:00 2001 From: Jeremy Skinner Date: Tue, 13 Mar 2018 15:14:01 +0000 Subject: [PATCH 13/16] Ensure sanitizer used is logged. Cleanup --- modules/gdpr_tasks/src/Anonymizer.php | 68 ++----------------- .../Plugin/Field/FieldType/TaskLogItem.php | 2 +- 2 files changed, 8 insertions(+), 62 deletions(-) diff --git a/modules/gdpr_tasks/src/Anonymizer.php b/modules/gdpr_tasks/src/Anonymizer.php index 8c76fbc..63da0f9 100644 --- a/modules/gdpr_tasks/src/Anonymizer.php +++ b/modules/gdpr_tasks/src/Anonymizer.php @@ -78,9 +78,10 @@ public function run(TaskInterface $task) { $success = TRUE; $msg = NULL; + $sanitizer = ''; if ($mode == 'anonymise') { - list($success, $msg) = $this->anonymize($field, $bundle_entity, $entity_type); + list($success, $msg, $sanitizer) = $this->anonymize($field, $bundle_entity); } elseif ($mode == 'remove') { list($success, $msg) = $this->remove($field); @@ -92,6 +93,7 @@ public function run(TaskInterface $task) { 'entity_type' => $bundle_entity->getEntityTypeId() . '.' . $bundle_entity->bundle(), 'field_name' => $field->getName(), 'action' => $mode, + 'sanitizer' => $sanitizer, ]; } else { @@ -148,23 +150,24 @@ private function remove(FieldItemListInterface $field) { } } - private function anonymize(FieldItemListInterface $field, EntityInterface $bundle_entity, $entity_type) { + private function anonymize(FieldItemListInterface $field, EntityInterface $bundle_entity) { $sanitizer_id = $this->getSanitizerId($field, $bundle_entity); if (!$sanitizer_id) { return [ FALSE, "Could not anonymize field {$field->getName()}. Please consider changing this field from 'anonymize' to 'remove', or register a custom sanitizer.", + NULL, ]; } try { $sanitizer = $this->sanitizerFactory->get($sanitizer_id); $field->setValue($sanitizer->sanitize($field->value, $field)); - return [TRUE, NULL]; + return [TRUE, NULL, $sanitizer_id]; } catch (\Exception $e) { - return [FALSE, $e->getMessage()]; + return [FALSE, $e->getMessage(), NULL]; } } @@ -237,61 +240,4 @@ private function refetchUser($user_id) { ->loadUnchanged($user_id); } - /** - * Generates an unique email address. - * - * Uses the timestamp to ensure this is unique. - * - * @throws \Drupal\Core\TypedData\Exception\ReadOnlyException - */ - public static function anonymizeMail(FieldItemListInterface $field) { - $mail = 'anon_' . time() . '@example.com'; - $field->setValue($mail); - } - - /** - * Replaces string field value with a random value if the field is non-empty. - * - * @throws \Drupal\Core\TypedData\Exception\ReadOnlyException - */ - public static function anonymizeString(FieldItemListInterface $field) { - $value = $field->getString(); - $max_length = $field->getDataDefinition()->getSetting("max_length"); - - if (!empty($value)) { - // Generate a prefixed random string. - $value = "anon_" . self::generateRandomString(4); - // If the value is too long, tirm it. - if (isset($max_length) && strlen($value) > $max_length) { - $value = substr(0, $max_length); - } - - $field->setValue($value); - } - } - - /** - * Replaces date field value with a random value if the field is non-empty. - * - * @throws \Drupal\Core\TypedData\Exception\ReadOnlyException - */ - public static function anonymizeDate(FieldItemListInterface $field) { - if (isset($field->value)) { - $field->setValue(date('1000-01-01')); - } - } - - /** - * Generates a random string of a specified length. - */ - private static function generateRandomString($length) { - $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; - $charactersLength = strlen($characters); - $randomString = ''; - for ($i = 0; $i < $length; $i++) { - $randomString .= $characters[rand(0, $charactersLength - 1)]; - } - return $randomString; - } - } diff --git a/modules/gdpr_tasks/src/Plugin/Field/FieldType/TaskLogItem.php b/modules/gdpr_tasks/src/Plugin/Field/FieldType/TaskLogItem.php index 1eda0a0..8f11393 100644 --- a/modules/gdpr_tasks/src/Plugin/Field/FieldType/TaskLogItem.php +++ b/modules/gdpr_tasks/src/Plugin/Field/FieldType/TaskLogItem.php @@ -65,7 +65,7 @@ public static function schema(FieldStorageDefinitionInterface $field_definition) 'sanitizer' => [ 'type' => 'varchar', 'length' => 255, - ] + ], ], ]; } From 42609c378a1d9aef4a8da2d8157492a1648c898a Mon Sep 17 00:00:00 2001 From: Jeremy Skinner Date: Wed, 14 Mar 2018 10:23:14 +0000 Subject: [PATCH 14/16] Doc comments and tweaks from code review --- .../Gdpr/Sanitizer/RandomTextSanitizer.php | 19 +---- modules/gdpr_fields/src/GDPRCollector.php | 1 + modules/gdpr_tasks/src/Anonymizer.php | 85 +++++++++++++++++-- 3 files changed, 83 insertions(+), 22 deletions(-) diff --git a/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/RandomTextSanitizer.php b/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/RandomTextSanitizer.php index cf2a89f..892147a 100644 --- a/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/RandomTextSanitizer.php +++ b/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/RandomTextSanitizer.php @@ -27,15 +27,13 @@ public function sanitize($input, FieldItemListInterface $field) { if ($field != NULL) { $max_length = $field->getDataDefinition()->getSetting("max_length"); } - else { - $max_length = NULL; - } $value = ''; if (!empty($input)) { // Generate a prefixed random string. - $value = "anon_" . $this->generateRandomString(4); + $rand = new Random(); + $value = "anon_" . $rand->string(4); // If the value is too long, tirm it. if (isset($max_length) && strlen($input) > $max_length) { $value = substr(0, $max_length); @@ -44,17 +42,4 @@ public function sanitize($input, FieldItemListInterface $field) { return $value; } - /** - * Generates a random string of the specified length. - */ - private function generateRandomString($length) { - $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; - $charactersLength = strlen($characters); - $randomString = ''; - for ($i = 0; $i < $length; $i++) { - $randomString .= $characters[rand(0, $charactersLength - 1)]; - } - return $randomString; - } - } diff --git a/modules/gdpr_fields/src/GDPRCollector.php b/modules/gdpr_fields/src/GDPRCollector.php index e9e681a..2d8dbd8 100644 --- a/modules/gdpr_fields/src/GDPRCollector.php +++ b/modules/gdpr_fields/src/GDPRCollector.php @@ -148,6 +148,7 @@ public function getValueEntities(array &$entity_list, $entity_type, EntityInterf } // Ignore links back to gdpr_task. + // @todo Remove this once we have solved how to deal with ignored/excluded relationships if ($related_entity_type == 'gdpr_task') { continue; } diff --git a/modules/gdpr_tasks/src/Anonymizer.php b/modules/gdpr_tasks/src/Anonymizer.php index 63da0f9..cd99ae2 100644 --- a/modules/gdpr_tasks/src/Anonymizer.php +++ b/modules/gdpr_tasks/src/Anonymizer.php @@ -2,8 +2,6 @@ namespace Drupal\gdpr_tasks; -use Drupal\Component\Annotation\Plugin; -use Drupal\Component\Plugin\Exception\PluginException; use Drupal\Core\Database\Connection; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; @@ -14,23 +12,52 @@ use Drupal\gdpr_dump\Sanitizer\GdprSanitizerFactory; use Drupal\gdpr_fields\GDPRCollector; use Drupal\gdpr_tasks\Entity\TaskInterface; -use Drupal\user\Entity\User; /** * Anonymizes or removes field values for GDPR. */ class Anonymizer { + /** + * Collector used to retrieve properties to anonymise. + * + * @var \Drupal\gdpr_fields\GDPRCollector + */ private $collector; + /** + * Database instance for the request. + * + * @var \Drupal\Core\Database\Connection + */ private $db; + /** + * Entity Type manager used to retrieve field storage info. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ private $entityTypeManager; + /** + * Drupal module handler for hooks. + * + * @var \Drupal\Core\Extension\ModuleHandlerInterface + */ private $moduleHandler; + /** + * The current user. + * + * @var \Drupal\Core\Session\AccountProxyInterface + */ private $currentUser; + /** + * Factory used to retrieve sanitizer to use on a particular field. + * + * @var \Drupal\gdpr_dump\Sanitizer\GdprSanitizerFactory + */ private $sanitizerFactory; /** @@ -47,6 +74,15 @@ public function __construct(GDPRCollector $collector, Connection $db, EntityType /** * Runs anonymization routines against a user. + * + * @param \Drupal\gdpr_tasks\Entity\TaskInterface $task + * The current task being executed. + * + * @return array + * Returns array containing any error messages. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Core\TypedData\Exception\ReadOnlyException */ public function run(TaskInterface $task) { // Make sure we load a fresh copy of the entity (bypassing the cache) @@ -139,6 +175,12 @@ public function run(TaskInterface $task) { /** * Removes the field value. + * + * @param \Drupal\Core\Field\FieldItemListInterface $field + * The current field to process. + * + * @return array + * First element is success boolean, second element is the error message. */ private function remove(FieldItemListInterface $field) { try { @@ -150,6 +192,17 @@ private function remove(FieldItemListInterface $field) { } } + /** + * Runs anonymise functionality against a field. + * + * @param \Drupal\Core\Field\FieldItemListInterface $field + * The field to anonymise. + * @param \Drupal\Core\Entity\EntityInterface $bundle_entity + * The parent entity. + * + * @return array + * First element is success boolean, second element is the error message. + */ private function anonymize(FieldItemListInterface $field, EntityInterface $bundle_entity) { $sanitizer_id = $this->getSanitizerId($field, $bundle_entity); @@ -172,6 +225,17 @@ private function anonymize(FieldItemListInterface $field, EntityInterface $bundl } + /** + * Gets the ID of the sanitizer plugin to use on this field. + * + * @param \Drupal\Core\Field\FieldItemListInterface $field + * The field to anonymise. + * @param \Drupal\Core\Entity\EntityInterface $bundle_entity + * The parent entity. + * + * @return string + * The sanitizer ID or null. + */ private function getSanitizerId(FieldItemListInterface $field, EntityInterface $bundle_entity) { // First check if this field has a sanitizer defined. $fieldDefinition = $field->getFieldDefinition(); @@ -181,7 +245,8 @@ private function getSanitizerId(FieldItemListInterface $field, EntityInterface $ ->getThirdPartySetting('gdpr_fields', 'gdpr_fields_sanitizer'); if (!$sanitizer) { - // No sanitizer defined directly on the field. Instead try and get one for the datatype. + // No sanitizer defined directly on the field. + // Instead try and get one for the datatype. $sanitizers = [ 'string' => 'gdpr_text_sanitizer', 'datetime' => 'gdpr_date_sanitizer', @@ -193,9 +258,15 @@ private function getSanitizerId(FieldItemListInterface $field, EntityInterface $ return $sanitizer; } - /** * Gets fields to anonymize/remove. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity to anonymise. + * + * @return array + * Array containing metadata about the entity. + * Elements are entity_type, bundle, field and mode. */ private function getFieldsToProcess(EntityInterface $entity) { $bundle_id = $entity->bundle(); @@ -231,7 +302,11 @@ private function getFieldsToProcess(EntityInterface $entity) { /** * Re-fetches the user bypassing the cache. * + * @param string $user_id + * The ID of the user to fetch. + * * @return \Drupal\user\Entity\User + * The user that was fetched. * * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException */ From a4117aa723ee196b747e10ee3fdabad2e6c85f65 Mon Sep 17 00:00:00 2001 From: Jeremy Skinner Date: Wed, 14 Mar 2018 14:24:13 +0000 Subject: [PATCH 15/16] Add export functionality on completion of RTF task. --- modules/gdpr_tasks/gdpr_tasks.links.menu.yml | 6 ++ modules/gdpr_tasks/gdpr_tasks.routing.yml | 10 +++ modules/gdpr_tasks/gdpr_tasks.services.yml | 2 +- modules/gdpr_tasks/src/Anonymizer.php | 60 ++++++++++++++++- .../src/Form/RemovalSettingsForm.php | 65 +++++++++++++++++++ 5 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 modules/gdpr_tasks/src/Form/RemovalSettingsForm.php diff --git a/modules/gdpr_tasks/gdpr_tasks.links.menu.yml b/modules/gdpr_tasks/gdpr_tasks.links.menu.yml index 054f5ef..56f98f0 100644 --- a/modules/gdpr_tasks/gdpr_tasks.links.menu.yml +++ b/modules/gdpr_tasks/gdpr_tasks.links.menu.yml @@ -13,3 +13,9 @@ entity.gdpr_task_type.collection: description: 'List Task type (bundles)' parent: system.admin_structure weight: 99 + +gdpr_task_remove.settings: + title: 'Right to be Forgotten' + description: 'Settings for configuring the Right to be Forgotten export' + parent: gdpr.admin_config + route_name: gdpr_tasks.remove_settings diff --git a/modules/gdpr_tasks/gdpr_tasks.routing.yml b/modules/gdpr_tasks/gdpr_tasks.routing.yml index 949af76..2d244f0 100644 --- a/modules/gdpr_tasks/gdpr_tasks.routing.yml +++ b/modules/gdpr_tasks/gdpr_tasks.routing.yml @@ -19,3 +19,13 @@ gdpr_tasks.request: type: string user: type: entity:user + +gdpr_tasks.remove_settings: + path: '/admin/config/gdpr/remove-settings' + defaults: + _form: '\Drupal\gdpr_tasks\Form\RemovalSettingsForm' + _title: 'Right to be Forgotten' + requirements: + _permission: 'access administration pages' + options: + _admin_route: TRUE diff --git a/modules/gdpr_tasks/gdpr_tasks.services.yml b/modules/gdpr_tasks/gdpr_tasks.services.yml index 02df2b1..b4321c3 100644 --- a/modules/gdpr_tasks/gdpr_tasks.services.yml +++ b/modules/gdpr_tasks/gdpr_tasks.services.yml @@ -4,4 +4,4 @@ services: arguments: ['@entity_type.manager', '@current_user'] gdpr_tasks.anonymizer: class: Drupal\gdpr_tasks\Anonymizer - arguments: ['@gdpr_fields.collector', '@database', '@entity_type.manager', '@module_handler', '@current_user', '@gdpr_dump.sanitizer_factory'] \ No newline at end of file + arguments: ['@gdpr_fields.collector', '@database', '@entity_type.manager', '@module_handler', '@current_user', '@gdpr_dump.sanitizer_factory', '@config.factory'] \ No newline at end of file diff --git a/modules/gdpr_tasks/src/Anonymizer.php b/modules/gdpr_tasks/src/Anonymizer.php index cd99ae2..91b59e0 100644 --- a/modules/gdpr_tasks/src/Anonymizer.php +++ b/modules/gdpr_tasks/src/Anonymizer.php @@ -2,6 +2,7 @@ namespace Drupal\gdpr_tasks; +use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Database\Connection; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; @@ -12,6 +13,7 @@ use Drupal\gdpr_dump\Sanitizer\GdprSanitizerFactory; use Drupal\gdpr_fields\GDPRCollector; use Drupal\gdpr_tasks\Entity\TaskInterface; +use Drupal\gdpr_tasks\Form\RemovalSettingsForm; /** * Anonymizes or removes field values for GDPR. @@ -60,16 +62,24 @@ class Anonymizer { */ private $sanitizerFactory; + /** + * Config factory. + * + * @var \Drupal\Core\Config\ConfigFactoryInterface + */ + private $configFactory; + /** * Anonymizer constructor. */ - public function __construct(GDPRCollector $collector, Connection $db, EntityTypeManagerInterface $entity_manager, ModuleHandlerInterface $module_handler, AccountProxyInterface $current_user, GdprSanitizerFactory $sanitizer_factory) { + public function __construct(GDPRCollector $collector, Connection $db, EntityTypeManagerInterface $entity_manager, ModuleHandlerInterface $module_handler, AccountProxyInterface $current_user, GdprSanitizerFactory $sanitizer_factory, ConfigFactoryInterface $config_factory) { $this->collector = $collector; $this->db = $db; $this->entityTypeManager = $entity_manager; $this->moduleHandler = $module_handler; $this->currentUser = $current_user; $this->sanitizerFactory = $sanitizer_factory; + $this->configFactory = $config_factory; } /** @@ -93,9 +103,12 @@ public function run(TaskInterface $task) { $entities = []; $successes = []; $failures = []; - $log = []; + if (!$this->checkExportDirectoryExists()) { + $errors[] = 'An export directory has not been set. Please set this under Configuration -> GDPR -> Right to be Forgotten'; + } + $this->collector->getValueEntities($entities, 'user', $user); foreach ($entities as $entity_type => $bundles) { @@ -163,6 +176,8 @@ public function run(TaskInterface $task) { $user = $this->refetchUser($task->getOwnerId()); $user->block(); $user->save(); + + $this->writeLogToFile($task, $log); } catch (\Exception $e) { $tx->rollBack(); @@ -315,4 +330,45 @@ private function refetchUser($user_id) { ->loadUnchanged($user_id); } + /** + * Checks that the export directory has been set. + * + * @return bool + * Indicates whether the export directory has been configured and exists. + */ + private function checkExportDirectoryExists() { + $directory = $this->configFactory->get(RemovalSettingsForm::CONFIG_KEY) + ->get(RemovalSettingsForm::EXPORT_DIRECTORY); + + return !empty($directory) && file_prepare_directory($directory); + } + + /** + * Stores the task log to the configured directory as JSON. + * + * @param \Drupal\gdpr_tasks\Entity\TaskInterface $task + * The task in progress. + * @param array $log + * Log of processed fields. + */ + private function writeLogToFile(TaskInterface $task, array $log) { + $filename = 'GDPR_RTF_' . date('Y-m-d H-i-s') . '_' . $task->id() . '.json'; + $dir = $this->configFactory->get(RemovalSettingsForm::CONFIG_KEY) + ->get(RemovalSettingsForm::EXPORT_DIRECTORY); + + $filename = $dir . '/' . $filename; + + // Don't serialize the whole entity as we don't need all fields. + + $output = [ + 'task_id' => $task->id(), + 'owner_id' => $task->getOwnerId(), + 'created' => $task->getCreatedTime(), + 'processed_by' => $this->currentUser->id(), + 'log' => $log, + ]; + + file_put_contents($filename, json_encode($output)); + } + } diff --git a/modules/gdpr_tasks/src/Form/RemovalSettingsForm.php b/modules/gdpr_tasks/src/Form/RemovalSettingsForm.php new file mode 100644 index 0000000..b46c60d --- /dev/null +++ b/modules/gdpr_tasks/src/Form/RemovalSettingsForm.php @@ -0,0 +1,65 @@ + 'textfield', + '#title' => 'Export Directory', + '#description' => 'Specifies the path to the directory where Right to be Forgotten tasks are exported after being processed', + '#default_value' => $this->config(self::CONFIG_KEY)->get(self::EXPORT_DIRECTORY), + ]; + return parent::buildForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + parent::validateForm($form, $form_state); + if ($form_state->hasValue('directory')) { + $dir = $form_state->getValue('directory'); + if (!empty($dir) && !file_prepare_directory($dir)) { + $form_state->setErrorByName('directory', $this->t('The directory does not exist.')); + } + } + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + + $this->config(self::CONFIG_KEY) + ->set(self::EXPORT_DIRECTORY, $form_state->getValue('directory')) + ->save(); + + \Drupal::messenger()->addStatus('Changes saved.'); + } +} From a515bb2d544f8d40baf3828effefb06b09613204 Mon Sep 17 00:00:00 2001 From: Jeremy Skinner Date: Wed, 14 Mar 2018 14:26:57 +0000 Subject: [PATCH 16/16] Tidy up --- modules/gdpr_tasks/src/Anonymizer.php | 1 - modules/gdpr_tasks/src/Form/RemovalSettingsForm.php | 12 +++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/modules/gdpr_tasks/src/Anonymizer.php b/modules/gdpr_tasks/src/Anonymizer.php index 91b59e0..e5d4497 100644 --- a/modules/gdpr_tasks/src/Anonymizer.php +++ b/modules/gdpr_tasks/src/Anonymizer.php @@ -359,7 +359,6 @@ private function writeLogToFile(TaskInterface $task, array $log) { $filename = $dir . '/' . $filename; // Don't serialize the whole entity as we don't need all fields. - $output = [ 'task_id' => $task->id(), 'owner_id' => $task->getOwnerId(), diff --git a/modules/gdpr_tasks/src/Form/RemovalSettingsForm.php b/modules/gdpr_tasks/src/Form/RemovalSettingsForm.php index b46c60d..e31a9f1 100644 --- a/modules/gdpr_tasks/src/Form/RemovalSettingsForm.php +++ b/modules/gdpr_tasks/src/Form/RemovalSettingsForm.php @@ -1,14 +1,19 @@ 'textfield', '#title' => 'Export Directory', '#description' => 'Specifies the path to the directory where Right to be Forgotten tasks are exported after being processed', - '#default_value' => $this->config(self::CONFIG_KEY)->get(self::EXPORT_DIRECTORY), + '#default_value' => $this->config(self::CONFIG_KEY) + ->get(self::EXPORT_DIRECTORY), ]; return parent::buildForm($form, $form_state); }