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; } 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..36b2065 --- /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..892147a --- /dev/null +++ b/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/RandomTextSanitizer.php @@ -0,0 +1,45 @@ +getDataDefinition()->getSetting("max_length"); + } + + $value = ''; + + if (!empty($input)) { + // Generate a prefixed random string. + $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); + } + } + return $value; + } + +} diff --git a/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/TextSanitizer.php b/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/TextSanitizer.php index c013332..a00e3db 100644 --- a/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/TextSanitizer.php +++ b/modules/gdpr_dump/src/Plugin/Gdpr/Sanitizer/TextSanitizer.php @@ -3,13 +3,14 @@ namespace Drupal\gdpr_dump\Plugin\Gdpr\Sanitizer; use Drupal\Component\Utility\Random; +use Drupal\Core\Field\FieldItemListInterface; use Drupal\gdpr_dump\Sanitizer\GdprSanitizerBase; /** * 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.") * ) @@ -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..aa92924 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; @@ -10,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.") * ) @@ -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_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_fields/gdpr_fields.module b/modules/gdpr_fields/gdpr_fields.module index 1f084d4..392ab51 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('Sanitizer 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'; } @@ -87,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_fields/src/GDPRCollector.php b/modules/gdpr_fields/src/GDPRCollector.php index 895e6a9..2d8dbd8 100644 --- a/modules/gdpr_fields/src/GDPRCollector.php +++ b/modules/gdpr_fields/src/GDPRCollector.php @@ -140,13 +140,19 @@ 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. + // @todo Remove this once we have solved how to deal with ignored/excluded relationships + 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); @@ -308,6 +314,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/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/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.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.module b/modules/gdpr_tasks/gdpr_tasks.module index 783a5ca..4bd176f 100644 --- a/modules/gdpr_tasks/gdpr_tasks.module +++ b/modules/gdpr_tasks/gdpr_tasks.module @@ -192,4 +192,28 @@ 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) { + 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['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'] = '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'; + $fields['roles']['third_party_settings']['gdpr_fields']['gdpr_fields_rtf'] = 'remove'; + } +} 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 b956a2f..b4321c3 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', '@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 new file mode 100644 index 0000000..e5d4497 --- /dev/null +++ b/modules/gdpr_tasks/src/Anonymizer.php @@ -0,0 +1,373 @@ +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; + } + + /** + * 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) + // so we don't end up affecting any other references to the entity. + $user = $task->getOwner(); + + $errors = []; + $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) { + 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; + + 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; + $sanitizer = ''; + + if ($mode == 'anonymise') { + list($success, $msg, $sanitizer) = $this->anonymize($field, $bundle_entity); + } + elseif ($mode == 'remove') { + list($success, $msg) = $this->remove($field); + } + + if ($success === TRUE) { + $log[] = [ + 'entity_id' => $bundle_entity->id(), + 'entity_type' => $bundle_entity->getEntityTypeId() . '.' . $bundle_entity->bundle(), + 'field_name' => $field->getName(), + 'action' => $mode, + 'sanitizer' => $sanitizer, + ]; + } + else { + // 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; + } + } + } + + $task->get('removal_log')->setValue($log); + + if (count($failures) === 0) { + $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($task->getOwnerId()); + $user->block(); + $user->save(); + + $this->writeLogToFile($task, $log); + } + catch (\Exception $e) { + $tx->rollBack(); + $errors[] = $e->getMessage(); + } + } + + return $errors; + } + + /** + * 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 { + $field->setValue(NULL); + return [TRUE, NULL]; + } + catch (ReadOnlyException $e) { + return [FALSE, $e->getMessage()]; + } + } + + /** + * 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); + + 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, $sanitizer_id]; + } + catch (\Exception $e) { + return [FALSE, $e->getMessage(), NULL]; + } + + } + + /** + * 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(); + $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' => 'gdpr_text_sanitizer', + 'datetime' => 'gdpr_date_sanitizer', + ]; + + $this->moduleHandler->alter('gdpr_type_sanitizers', $sanitizers); + $sanitizer = $sanitizers[$type]; + } + 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(); + + // 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; + } + + /** + * 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 + */ + private function refetchUser($user_id) { + return $this->entityTypeManager->getStorage('user') + ->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/Entity/Task.php b/modules/gdpr_tasks/src/Entity/Task.php index fdd5c45..d554e44 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,31 @@ 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', [ + 'type' => 'author', + 'weight' => 5, + ]) + ->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/RemovalSettingsForm.php b/modules/gdpr_tasks/src/Form/RemovalSettingsForm.php new file mode 100644 index 0000000..e31a9f1 --- /dev/null +++ b/modules/gdpr_tasks/src/Form/RemovalSettingsForm.php @@ -0,0 +1,71 @@ + '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.'); + } +} diff --git a/modules/gdpr_tasks/src/Form/TaskActionsForm.php b/modules/gdpr_tasks/src/Form/TaskActionsForm.php index 7853206..4ae5795 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} + * Performs the SAR export. */ - 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} - */ - 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,71 @@ 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'); + $errors = $anonymizer->run($this->entity); + 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 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) { + $should_save = FALSE; + \Drupal::messenger()->addError(implode(' ', $errors)); + $form_state->setRebuild(); + } + else { + $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); + } } } 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..bdaeb6c --- /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} Sanitizer: {$item->sanitizer}", + ]; + } + + 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..8f11393 --- /dev/null +++ b/modules/gdpr_tasks/src/Plugin/Field/FieldType/TaskLogItem.php @@ -0,0 +1,72 @@ +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'); + + $properties['sanitizer'] = DataDefinition::create('string') + ->setLabel('Sanitizer'); + + 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, + ], + 'sanitizer' => [ + 'type' => 'varchar', + 'length' => 255, + ], + ], + ]; + } +} \ 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..de3430f --- /dev/null +++ b/modules/gdpr_tasks/src/Plugin/Field/FieldWidget/TaskLogItemWidget.php @@ -0,0 +1,56 @@ + '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', + ]; + + $element['sanitizer'] = [ + '#type' => 'textfield', + '#title' => 'Sanitizer', + ]; + + return $element; + } +} \ No newline at end of file