From cf6dd637a0eba05936e657fc2afea0f34d4ca5f0 Mon Sep 17 00:00:00 2001 From: Marcin Grabias Date: Mon, 12 Mar 2018 13:02:29 +0100 Subject: [PATCH 1/5] Updated to the last commit from the d.o. repo. --- README.txt | 82 +++++ commerce_gdpr.admin.inc | 139 ++++++++ commerce_gdpr.api.php | 44 +++ commerce_gdpr.info | 12 + commerce_gdpr.install | 42 +++ commerce_gdpr.module | 711 ++++++++++++++++++++++++++++++++++++++++ commerce_gdpr.test | 189 +++++++++++ commerce_gdpr.user.inc | 310 ++++++++++++++++++ 8 files changed, 1529 insertions(+) create mode 100644 commerce_gdpr.admin.inc create mode 100644 commerce_gdpr.api.php create mode 100644 commerce_gdpr.info create mode 100644 commerce_gdpr.install create mode 100644 commerce_gdpr.module create mode 100644 commerce_gdpr.test create mode 100644 commerce_gdpr.user.inc diff --git a/README.txt b/README.txt index e69de29..9c66635 100644 --- a/README.txt +++ b/README.txt @@ -0,0 +1,82 @@ +TABLE OF CONTENTS +----------------- + + * Introduction + * Requirements + * Installation + * Configuration + + +INTRODUCTION +------------ + +This module helps Drupal Commere sites comply with the EU GDPR directive by +providing means to anonymize user and order data. +Anonymized data can still be used for statistical purposes but will not allow +to identify a person. +The module adds UI for users to anonimize their data and means for the site +administrators to setup automatic anonymization after a defined amount of time. + + +REQUIREMENTS +------------ + +* Order module (commerce_order, included in the Drupal Commerce package + - https://www.drupal.org/project/commerce), +* Chaos tool suite (ctools, also required by Drupal Commerce + - https://www.drupal.org/project/ctools). + + +INSTALLATION +------------ + + * Install as you would normally install a contributed Drupal module. See: + https://drupal.org/documentation/install/modules-themes/modules-7 + for further information. + + +CONFIGURATION +------------- + +1. settings.php + +By default, the module uses md5 algorithm and the $drupal_hash_salt value +for hashing user data. It is strongly advised to change at least the salt +for improved security by including the following code in settings.php: + + +$conf['commerce_gdpr_salt'] = 'some_salt'; +$conf['commerce_gdpr_algo'] = 'some_algo'; + + +For enhanced security, you may set this variable to a value using the +contents of a file outside your docroot that is never saved together +with any backups of your Drupal files and database. + +Example: + +$conf['commerce_gdpr_salt'] = file_get_contents('/home/example/anonimyzation_salt.txt'); + + +The salt needs to be at least 22 characters long and can be generated once using +hash('some_algorithm', mt_rand()); +or just entered manually. + +For a list of supported hashing algorithms see +http://php.net/manual/en/function.hash-hmac-algos.php. + +NOTE: Those config values must be entered only once when the module is +installed and should not be changed after data has already been anonimized, +otherwise comparing data from before and after the change will be impossible. + + +2. Admin UI + +By default, only entity properties are anonimized and automatic anonimization +is switched off. To select entity felds that should also be anonimized and +time after automatic anonymization should take place, go to the module +configuration form (/admin/commerce/config/commerce-gdpr). + +3. Custom entity properties + +See commerce_gdpr.api.php. diff --git a/commerce_gdpr.admin.inc b/commerce_gdpr.admin.inc new file mode 100644 index 0000000..7115e01 --- /dev/null +++ b/commerce_gdpr.admin.inc @@ -0,0 +1,139 @@ + l(t('help page'), 'admin/help/commerce_gdpr', array( + 'attributes' => array('target' => '_blank'), + )), + )), 'warning'); + } + + $form['data_retention'] = array( + '#type' => 'textfield', + '#title' => t('Order data retention period'), + '#default_value' => variable_get('commerce_gdpr_data_retention', 0), + '#description' => t('Enter number of days after which inactive data will be anonymized, 0 - no automatic anonymization.'), + ); + + $form['direct_processing'] = array( + '#type' => 'checkbox', + '#title' => t('Process items immediately'), + '#description' => t('Process items immediately on the user form and during the "People" page bulk operation rather then enqueing the operations. NOTE: may cause performance issues in case of users with many orders.'), + '#default_value' => variable_get('commerce_gdpr_direct_processing', 0), + ); + + $form['user_button_text'] = array( + '#type' => 'textfield', + '#title' => t('User account anonymization button text'), + '#default_value' => variable_get('commerce_gdpr_user_button_text', 'I want to be forgotten'), + '#required' => TRUE, + ); + + $form['anonymized_fields'] = array( + '#type' => 'fieldset', + '#title' => t('Fields subject to anonymization'), + '#tree' => TRUE, + ); + + $entity_info = entity_get_info(); + $selected_fields = variable_get('commerce_gdpr_anonymized_fields', array()); + + foreach (array_keys(_commerce_gdpr_get_entity_property_info()) as $type) { + $entity_info = entity_get_info($type); + $form['anonymized_fields'][$type] = array( + '#type' => 'fieldset', + '#title' => $entity_info['label'], + ); + foreach ($entity_info['bundles'] as $bundle => $bundle_info) { + if (count($entity_info['bundles']) > 1) { + $form['anonymized_fields'][$type][$bundle] = array( + '#type' => 'fieldset', + '#title' => $bundle_info['label'], + ); + } + else { + $form['anonymized_fields'][$type]['#title'] = $bundle_info['label']; + } + + $field_instances = field_info_instances($type, $bundle); + if (empty($field_instances)) { + $form['anonymized_fields'][$type][$bundle]['info'] = array( + '#markup' => t('No field instances available.'), + ); + } + else { + foreach ($field_instances as $field_name => $instance_info) { + + // Excluse customer profile type fields, those will be anonymized + // along with the order. Also exclude line items. + // TODO: Actually all entity reference field types should be + // handled differently, but it's not needed at the moment. + $excluded_fields = array( + 'commerce_line_item_reference', + 'commerce_customer_profile_reference', + ); + $info = field_info_field($field_name); + if (in_array($info['type'], $excluded_fields)) { + continue; + } + + $form['anonymized_fields'][$type][$bundle][$field_name] = array( + '#type' => 'checkbox', + '#title' => $instance_info['label'], + '#default_value' => !empty($selected_fields[$type][$bundle][$field_name]), + ); + } + } + } + } + + $form['submit'] = array( + '#type' => 'submit', + '#value' => t('Save settings'), + ); + + return $form; +} + +/** + * Admin form validate handler. + */ +function commerce_gdpr_admin_form_validate($form, &$form_state) { + if (!is_numeric($form_state['values']['data_retention'])) { + form_set_error('data_retention', t('Enter a valid data retention value.')); + } +} + +/** + * Admin form submit handler. + */ +function commerce_gdpr_admin_form_submit($form, &$form_state) { + variable_set('commerce_gdpr_data_retention', $form_state['values']['data_retention']); + variable_set('commerce_gdpr_direct_processing', $form_state['values']['direct_processing']); + variable_set('commerce_gdpr_user_button_text', $form_state['values']['user_button_text']); + + $selected_fields = array(); + foreach ($form_state['values']['anonymized_fields'] as $type => $type_data) { + foreach ($type_data as $bundle => $bundle_data) { + foreach ($bundle_data as $field_name => $value) { + if ($value) { + $selected_fields[$type][$bundle][$field_name] = $field_name; + } + } + } + } + variable_set('commerce_gdpr_anonymized_fields', $selected_fields); + + drupal_set_message(t('Settings saved.')); +} diff --git a/commerce_gdpr.api.php b/commerce_gdpr.api.php new file mode 100644 index 0000000..c04dc0c --- /dev/null +++ b/commerce_gdpr.api.php @@ -0,0 +1,44 @@ +condition('nid', $entity->nid)->execute(); + } +} diff --git a/commerce_gdpr.info b/commerce_gdpr.info new file mode 100644 index 0000000..08be38a --- /dev/null +++ b/commerce_gdpr.info @@ -0,0 +1,12 @@ +name = Commerce GDPR +description = Allows user and order data anonymization for complience with the GDPR EU directive. +core = 7.x +package = Commerce (contrib) +configure = admin/commerce/config/commerce-gdpr +dependencies[] = commerce:commerce +dependencies[] = commerce:commerce_order +dependencies[] = ctools:ctools +files[] = commerce_gdpr.test + +test_dependencies[] = views:views +test_dependencies[] = addressfield:addressfield diff --git a/commerce_gdpr.install b/commerce_gdpr.install new file mode 100644 index 0000000..31a3de7 --- /dev/null +++ b/commerce_gdpr.install @@ -0,0 +1,42 @@ + 'Contains last access data for commerce entities containing user data.', + 'fields' => array( + 'type' => array( + 'description' => 'Entity type', + 'type' => 'varchar', + 'length' => 128, + 'not null' => TRUE, + ), + 'id' => array( + 'description' => 'Entity ID', + 'type' => 'int', + 'not null' => TRUE, + ), + 'last_access' => array( + 'description' => 'Last access timestamp', + 'type' => 'int', + 'size' => 'big', + 'not null' => TRUE, + ), + ), + 'primary key' => array('type', 'id'), + 'indexes' => array( + 'last_access' => array('last_access'), + ), + ); + + return $schema; +} diff --git a/commerce_gdpr.module b/commerce_gdpr.module new file mode 100644 index 0000000..11489aa --- /dev/null +++ b/commerce_gdpr.module @@ -0,0 +1,711 @@ + array( + 'name' => array( + 'type' => 'hash', + 'max_length' => 60, + ), + 'mail' => array( + 'type' => 'hash', + 'max_length' => 254, + ), + 'signature' => array( + 'type' => 'hash', + 'max_length' => 255, + ), + 'data' => array( + 'type' => 'clear', + ), + // Also disable the user account. + 'status' => array( + 'type' => 'value', + 'value' => 0, + ), + ), + + 'commerce_order' => array( + 'mail' => array( + 'type' => 'hash', + 'max_length' => 255, + ), + 'hostname' => array( + 'type' => 'hash', + 'max_length' => 255, + ), + // NOTE: revision_hostname is always populated automatically + // by CommerceOrderEntityController::save(), we can't overwrite + // this value. + 'data' => array( + 'type' => 'clear', + ), + ), + ); + + drupal_alter('commerce_gdpr_entity_property_info', $entity_property_info); + + // We can't save commerce_customer_profile without it being duplicated + // by commerce so modifying its properties is out of question. + $entity_property_info['commerce_customer_profile'] = array(); + } + + if (isset($type)) { + return $entity_property_info[$type]; + } + else { + return $entity_property_info; + } +} + +/** + * Implements hook_help(). + */ +function commerce_gdpr_help($path, $arg) { + if ($path === 'admin/help#commerce_gdpr') { + $filepath = dirname(__FILE__) . '/README.txt'; + if (file_exists($filepath)) { + $readme = file_get_contents($filepath); + if (module_exists('markdown')) { + $filters = module_invoke('markdown', 'filter_info'); + $info = $filters['filter_markdown']; + + if (function_exists($info['process callback'])) { + $output = $info['process callback']($readme, NULL); + } + else { + $output = '
' . $readme . '
'; + } + } + else { + $output = '
' . $readme . '
'; + } + + // Add a list of supported hash_hmac algorithms. + if (function_exists('hash_hmac_algos')) { + // As of PHP 7.2+. + $hash_hmac_algos = hash_hmac_algos(); + } + else { + // Before PHP 7.2. + // It may be that some of the algos may not be suitable for hash_hmac + // (see http://php.net/manual/en/function.hash-hmac-algos.php) + // although it' possibly a rare case. + $hash_hmac_algos = hash_algos(); + } + $renderable = array( + '#theme' => 'item_list', + '#title' => 'Supported hashing algorithms:', + '#items' => $hash_hmac_algos, + ); + $output .= drupal_render($renderable); + + return $output; + } + } +} + +/** + * Implements hook_permission(). + */ +function commerce_gdpr_permission() { + return array( + 'administer commerce gdpr' => array( + 'title' => t('Administer commerce GDPR'), + ), + 'anonymize other users data' => array( + 'title' => t('Anonymize data of other users'), + 'restrict access' => TRUE, + ), + ); +} + +/** + * Implements hook_menu(). + */ +function commerce_gdpr_menu() { + $items = array(); + + $items['admin/commerce/config/commerce-gdpr'] = array( + 'title' => 'Commerce GDPR', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('commerce_gdpr_admin_form'), + 'access arguments' => array('administer commerce gdpr'), + 'file' => 'commerce_gdpr.admin.inc', + ); + + $items['admin/people/commerce-gdpr-confirm'] = array( + 'title' => 'Confirm bulk anonymization', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('commerce_gdpr_user_bulk_confirm_form'), + 'access arguments' => array('anonymize other users data'), + 'type' => MENU_CALLBACK, + ); + + return $items; +} + +/** + * One-way string hashing. + * + * @param mixed $value + * The anonymized value: a string or a numeric type. + * @param array $params + * Parameters describing how anonymization should be performed. + */ +function commerce_gdpr_anonymize_value($value, array $params = array()) { + $hashing_params = &drupal_static(__FUNCTION__); + $output = NULL; + + switch ($params['type']) { + case 'hash': + if (!isset($hashing_params)) { + $hashing_params = array( + 'salt' => variable_get('commerce_gdpr_salt', $GLOBALS['drupal_hash_salt']), + 'algo' => variable_get('commerce_gdpr_algo', 'md5'), + ); + } + $output = hash_hmac($hashing_params['algo'], $value, $hashing_params['salt']); + break; + + case 'value': + if (isset($params['value'])) { + $output = $params['value']; + } + break; + + case 'clear': + if (is_numeric($value)) { + $output = 0; + } + else { + $output = ''; + } + break; + } + + // Shorten the optput to maximum allowable length if required. + if (!empty($params['max_length']) && is_string($output) && (strlen($output) > $params['max_length'])) { + $output = substr($output, 0, $params['max_length']); + } + + return $output; +} + +/** + * Update last updated information of an entity. + * + * @param string $type + * Entity type. + * @param int $id + * Entity ID. + * @param int $time + * Timestamp of last entity update. 0 means that the entity + * has already been anonymized. + */ +function _commerce_gdpr_update_info($type, $id, $time = REQUEST_TIME) { + + // Always skip user 1 and user 0. + if ($type === 'user' && ($id == 1 || $id == 0)) { + return; + } + + // Act only if the entity hasn't been anonymized yet. + $last_access = _commerce_gdpr_get_info($type, $id); + if ($last_access !== 0) { + $key = array( + 'type' => $type, + 'id' => $id, + ); + db_merge('commerce_gdpr_access') + ->key($key) + ->fields($key + ['last_access' => $time]) + ->execute(); + } +} + +/** + * Delete last updated information of an entity. + * + * @param string $type + * Entity type. + * @param int $id + * Entity ID. + */ +function _commerce_gdpr_delete_info($type, $id) { + db_delete('commerce_gdpr_access') + ->condition('type', $type) + ->condition('id', $id) + ->execute(); +} + +/** + * Get last updated information of an entity. + * + * @param string $type + * Entity type. + * @param int $id + * Entity ID. + * + * @return mixed + * An array of last access data keyed by entity type and id or a single + * last access timestamp if type and id conditions have been provided. + */ +function _commerce_gdpr_get_info($type = NULL, $id = NULL) { + $query = db_select('commerce_gdpr_access', 'cga'); + if (isset($type) && isset($id)) { + $query->fields('cga', array('last_access')); + if (isset($type)) { + $query->condition('type', $type); + } + if (isset($id)) { + $query->condition('id', $id); + } + $last_access = $query->execute()->fetchField(); + return $last_access === FALSE ? FALSE : intval($last_access); + } + else { + $query->fields('cga'); + if (isset($type)) { + $query->condition('type', $type); + } + if (isset($id)) { + $query->condition('id', $id); + } + $results = $query->execute()->fetchAll(PDO::FETCH_ASSOC); + $output = array(); + foreach ($results as $item) { + $output[$item['type']][$item['id']] = intval($item['last_access']); + } + return $output; + } + +} + +/** + * Update last update information using core and commerce hooks. + */ + +/** + * Implemens hook_entity_insert(). + */ +function commerce_gdpr_entity_insert($entity, $type) { + commerce_gdpr_entity_update($entity, $type); +} + +/** + * Helper hunction that gets all profiles and orders associated with a user. + */ +function _commerce_gdpr_get_user_queue_items($account) { + $queue_items = array(); + + // First check if the user wasn't anonymized before. + // Use the anonymized user email as an identifier. + $user_property_info = _commerce_gdpr_get_entity_property_info('user'); + $anonymized_mail = commerce_gdpr_anonymize_value($account->mail, $user_property_info['mail']); + $uid = db_select('users', 'u') + ->fields('u', array('uid')) + ->condition('mail', $anonymized_mail) + ->execute() + ->fetchField(); + + // Also check the user name that has to be unique. + // If a user with the same name but different email existed, change the + // currently anonymized user name. + if ($uid === FALSE) { + $name_base = $account->name; + $iteration = 0; + do { + $anonymized_username = commerce_gdpr_anonymize_value($account->name, $user_property_info['name']); + + $check = db_select('users', 'u') + ->fields('u', array('uid')) + ->condition('name', $anonymized_username) + ->condition('uid', $account->uid, '<>') + ->execute() + ->fetchField(); + if ($check) { + $account->name = $name_base . '_' . ($iteration++); + } + } while ($check); + + if ($name_base !== $account->name) { + user_save($account); + } + } + + // Get orders. + $orders = commerce_order_load_multiple(array(), array('uid' => $account->uid)); + foreach ($orders as $order) { + if (empty($order->anonymized)) { + $queue_items[] = array( + 'type' => 'commerce_order', + 'id' => $order->order_id, + 'uid' => $uid, + ); + } + } + + // Get profiles. + $profiles = commerce_customer_profile_load_multiple(array(), array('uid' => $account->uid)); + foreach ($profiles as $profile) { + if (empty($profile->anonymized)) { + $queue_items[] = array( + 'type' => 'commerce_customer_profile', + 'id' => $profile->profile_id, + 'uid' => $uid, + ); + } + } + + // Last item: delete or anonymize the user account. + if ($uid) { + $queue_items[] = array( + 'type' => 'user', + 'id' => $account->uid, + 'delete' => TRUE, + ); + } + elseif ($account->uid !== 1) { + if (empty($account->anonymized)) { + $queue_items[] = array( + 'type' => 'user', + 'id' => $account->uid, + ); + } + } + + return $queue_items; +} + +/** + * Implemens hook_entity_update(). + */ +function commerce_gdpr_entity_update($entity, $type) { + if (!in_array($type, array_keys(_commerce_gdpr_get_entity_property_info())) || !empty($entity->commerce_gdpr_anonymization)) { + return; + } + + // Update the last access of the entity owner if not owned by an anonymous + // user. + if (isset($entity->uid) && !empty($entity->uid)) { + _commerce_gdpr_update_info('user', $entity->uid); + } + else { + list($id,,) = entity_extract_ids($type, $entity); + _commerce_gdpr_update_info($type, $id); + } +} + +/** + * Implemens hook_entity_delete(). + */ +function commerce_gdpr_entity_delete($entity, $type) { + if (!in_array($type, array_keys(_commerce_gdpr_get_entity_property_info()))) { + return; + } + + list($id,,) = entity_extract_ids($type, $entity); + _commerce_gdpr_delete_info($type, $id); +} + +/** + * Implements hook_entity_view(). + */ +function commerce_gdpr_entity_load($entities, $type) { + if (!in_array($type, array_keys(_commerce_gdpr_get_entity_property_info()))) { + return; + } + + $ids = array(); + foreach ($entities as $id => $entity) { + $ids[] = $id; + } + $query = db_select('commerce_gdpr_access', 'cga'); + $query->fields('cga', array('id', 'last_access')); + $query->condition('type', $type); + $query->condition('id', $ids); + $last_access_data = $query->execute()->fetchAllKeyed(); + foreach ($last_access_data as $id => $last_access) { + if ($last_access == 0) { + $entities[$id]->anonymized = TRUE; + } + } + +} + +/** + * Implemens hook_user_login(). + */ +function commerce_gdpr_user_login(&$edit, $account) { + // Refresh user orders and profiles access on every user login. + $orders = commerce_order_load_multiple(array(), array('uid' => $account->uid)); + foreach ($orders as $order) { + commerce_gdpr_entity_update($order, 'commerce_order'); + } + + _commerce_gdpr_update_info('user', $account->uid); +} + +/** + * Data anonymization function. + * + * Anonymizes an entity and all its revisions, if any. + * + * @param string $type + * Entity type. + * @param object $entity + * Drupal entity. + * @param array $field_data + * Array of field anonymization data. + * @param bool $is_default_revision + * Is this the default revision of an entity? Internal use only. + * @param bool $force + * If set to TRUE, entity will be anonymized even if + * it is already marked as anonymized. + */ +function commerce_gdpr_anonymize_entity($type, $entity, array $field_data = NULL, $is_default_revision = TRUE, $force = FALSE) { + list($id, $vid, $bundle) = entity_extract_ids($type, $entity); + + // Set commerce_gdpr parameter to prevent executing other module hooks. + $entity->commerce_gdpr_anonymization = TRUE; + + // Check if the entity is already anonymized. + if (!$force) { + $last_access = _commerce_gdpr_get_info($type, $id); + if ($last_access === 0) { + return; + } + } + + $entity_info = entity_get_info($type); + + // Get anonymized fields and properties data. + if (!isset($field_data)) { + $fields = variable_get('commerce_gdpr_anonymized_fields', array()); + $field_data = isset($fields[$type][$bundle]) ? $fields[$type][$bundle] : array(); + } + $properties_data = _commerce_gdpr_get_entity_property_info($type); + + // Go for entity revisions first. NOTE: Why is there no API to + // load all revisions? + if ($is_default_revision) { + + // Allow other modules do their job. + module_invoke_all('commerce_gdpr_entity_anonymization', $type, $entity, $properties_data, $field_data); + + if (!empty($entity_info['revision table'])) { + $revision_ids = db_select($entity_info['revision table'], 'revision') + ->fields('revision', array($entity_info['entity keys']['revision'])) + ->condition('revision.' . $entity_info['entity keys']['id'], $id) + ->condition('revision.' . $entity_info['entity keys']['revision'], $vid, '<>') + ->execute() + ->fetchCol(); + + foreach ($revision_ids as $revision_id) { + $revisions = entity_get_controller($type)->load(FALSE, array( + $entity_info['entity keys']['revision'] => $revision_id, + )); + $revision = reset($revisions); + commerce_gdpr_anonymize_entity($type, $revision, $field_data, FALSE, $force); + } + } + } + + // TODO: Are there any other data types that should be subject to + // anonymization? Other data types will be cleared. + $hashed_data_types = array( + 'char', + 'varchar', + ); + + $updated = FALSE; + + if (!empty($field_data)) { + $anonymization_data = array(); + foreach ($field_data as $field_name) { + if (!empty($entity->{$field_name})) { + + // Determine anonymization process parameters. + $field_info = field_info_field($field_name); + if (!empty($field_info['columns'])) { + foreach ($field_info['columns'] as $column => $spec) { + if (isset($spec['type'])) { + if (in_array($spec['type'], $hashed_data_types)) { + $anonymization_data[$field_name][$column] = array( + 'type' => 'hash', + 'max_length' => isset($spec['length']) ? $spec['length'] : 0, + ); + } + else { + $anonymization_data[$field_name][$column] = array( + 'type' => 'clear', + ); + } + } + } + } + } + } + + // Anonymize field values. + if (!empty($anonymization_data)) { + $updated = TRUE; + foreach ($anonymization_data as $field_name => $column_data) { + foreach ($entity->{$field_name} as $langcode => $items) { + foreach ($items as $delta => $item) { + foreach ($item as $column => $value) { + if (isset($column_data[$column])) { + $entity->{$field_name}[$langcode][$delta][$column] = commerce_gdpr_anonymize_value($value, $column_data[$column]); + } + } + } + } + } + } + } + + // Anonymize entity properties. + if (!empty($properties_data)) { + $updated = TRUE; + foreach ($properties_data as $property => $property_data) { + if (!empty($entity->{$property})) { + $entity->{$property} = commerce_gdpr_anonymize_value($entity->{$property}, $property_data); + } + } + entity_metadata_wrapper($type, $entity)->save(); + } + + // If no properties have were changed, it's enough to save field + // values. Also a workaround for customer profile duplication. + elseif ($updated) { + field_attach_update($type, $entity); + if ($is_default_revision) { + entity_get_controller($type)->resetCache(array($id)); + } + } + + // Mark the entity as anonymized in db. + if ($is_default_revision) { + _commerce_gdpr_update_info($type, $id, 0); + } +} + +/** + * Implements hook_user_operations(). + */ +function commerce_gdpr_user_operations() { + if (user_access('anonymize other users data')) { + return array( + 'anonymize' => array( + 'label' => t('Anonymize the selected users data'), + 'callback' => 'commerce_gdpr_user_operations_anonymize_router', + ), + ); + } +} + +/** + * Implements hook_cron_queue_info(). + */ +function commerce_gdpr_cron_queue_info() { + $queues['commerce_gdpr_anonymization'] = array( + 'worker callback' => 'commerce_gdpr_anonymization_worker', + 'time' => 30, + ); + return $queues; +} + +/** + * Queue worker callback. + */ +function commerce_gdpr_anonymization_worker($item) { + + $entities = entity_get_controller($item['type'])->load(array($item['id'])); + if (!empty($entities)) { + $entity = reset($entities); + if (!empty($item['delete'])) { + entity_metadata_wrapper($item['type'], $entity)->delete(); + } + else { + commerce_gdpr_anonymize_entity($item['type'], $entity); + + // Optionally change ownership of the entity. + if (!empty($item['uid'])) { + if ($item['type'] === 'commerce_customer_profile') { + db_update('commerce_customer_profile') + ->fields(array('uid' => $item['uid'])) + ->condition('profile_id', $entity->profile_id) + ->execute(); + } + else { + $entity->uid = $item['uid']; + entity_metadata_wrapper($item['type'], $entity)->save(); + } + } + } + } +} + +/** + * Implements hook_cron(). + */ +function commerce_gdpr_cron() { + $data_retention = variable_get('commerce_gdpr_data_retention', 0); + + if ($data_retention) { + $condition = REQUEST_TIME - $data_retention * 24 * 3600; + $results = db_select('commerce_gdpr_access', 'cga') + ->fields('cga') + ->condition('last_access', 0, '>') + ->condition('last_access', $condition, '<') + ->execute() + ->fetchAll(PDO::FETCH_ASSOC); + + if (!empty($results)) { + $queue = DrupalQueue::get('commerce_gdpr_anonymization'); + + // Continue only if there are no items in the queue, otherwise we + // could end up processing same items multiple times. + if ($queue->numberOfItems() > 0) { + return; + } + + // Add items to be processed in the next cron queue. + foreach ($results as $item) { + if ($item['type'] === 'user') { + $account = user_load($item['id']); + $items = _commerce_gdpr_get_user_queue_items($account); + foreach ($items as $item) { + $queue->createItem($item); + } + } + else { + $queue->createItem($item); + } + } + } + } +} diff --git a/commerce_gdpr.test b/commerce_gdpr.test new file mode 100644 index 0000000..419b380 --- /dev/null +++ b/commerce_gdpr.test @@ -0,0 +1,189 @@ + 'Commerce GDPR unit tests', + 'description' => 'Test proper operation of key Commerce GPR functions.', + 'group' => 'Drupal Commerce', + ); + } + + /** + * Set up the test environment. + * + * Note that we use drupal_load() instead of passing our module dependency + * to parent::setUp(). That's because we're using DrupalUnitTestCase, and + * thus we don't want to install the module, only load it's code. + * + * Also, DrupalUnitTestCase can't actually install modules. This is by + * design. + */ + public function setUp() { + drupal_load('module', 'commerce_gdpr'); + $GLOBALS['drupal_hash_salt'] = hash('md5', mt_rand()); + parent::setUp(); + } + + /** + * Test commerce_gdpr_anonymize_value(). + */ + public function testAnonymizeValueFunction() { + $value = mt_rand(); + + $expected = hash_hmac('md5', $value, $GLOBALS['drupal_hash_salt']); + $actual = commerce_gdpr_anonymize_value($value, array( + 'type' => 'hash', + )); + $this->assertEqual($expected, $actual, 'Is the input hashed?'); + + $actual = commerce_gdpr_anonymize_value($value, array( + 'type' => 'clear', + )); + $this->assertEqual(0, $actual, 'Is the input cleared?'); + } + +} + +/** + * Includes tests for the most important functions of the module. + * + * @see DrupalUnitTestCase + * + * @ingroup commerce_gdpr + */ +class CommerceGdprFunctionalTestCase extends CommerceBaseTestCase { + + protected $profile = 'minimal'; + + /** + * {@inheritdoc} + */ + public static function getInfo() { + return array( + 'name' => 'Commerce GDPR functional tests', + 'description' => 'Test proper operation of key Commerce GPR functions.', + 'group' => 'Drupal Commerce', + ); + } + + /** + * {@inheritdoc} + */ + public function setUp() { + // We call parent::setUp() with the list of modules we want to enable. + $modules = parent::setUpHelper('all'); + $modules[] = 'commerce_gdpr'; + parent::setUp($modules); + $GLOBALS['drupal_hash_salt'] = hash('md5', mt_rand()); + $this->example_value = 'example value'; + $this->expected = hash_hmac('md5', $this->example_value, $GLOBALS['drupal_hash_salt']); + } + + /** + * Order anonymization test. + */ + protected function orderAnonymizationTest() { + $order = new stdClass(); + $order->order_id = 1; + $order->type = 'commerce_order'; + $order->status = 'pending'; + $order->created = REQUEST_TIME; + $order->uid = 1; + $order->revision_id = 1; + $order->hostname = $this->example_value; + $order->mail = $this->example_value; + + commerce_gdpr_anonymize_entity('commerce_order', $order); + + $this->assertEqual($this->expected, $order->mail, 'Is the order mail anonymized properly?'); + } + + /** + * Customer profile anonymization test. + * + * Tests if entity field values are anonymized. + */ + protected function profileAnonymizationTest() { + $profile = new stdClass(); + $profile->profile_id = 1; + $profile->type = 'billing'; + $profile->commerce_customer_address = array( + LANGUAGE_NONE => array( + array( + 'name_line' => $this->example_value, + ), + ), + ); + $fields = array( + 'commerce_customer_address' => 'commerce_customer_address', + ); + commerce_gdpr_anonymize_entity('commerce_customer_profile', $profile, $fields); + + $this->assertEqual($this->expected, $profile->commerce_customer_address[LANGUAGE_NONE][0]['name_line'], 'Is the customer profile name_line address field item anonymized properly?'); + } + + /** + * Kernel tests for the module. + * + * The included tests can be run on the same environment, + * so they are placed in one method to save execution time. + */ + public function testAnonymizeEntityFunction() { + + $this->orderAnonymizationTest(); + $this->profileAnonymizationTest(); + } + + /** + * Test the user anonymization path. + */ + public function testUserAnonymization() { + $store_customer = $this->createStoreCustomer(); + + $admin_permissions = $this->permissionBuilder('store admin'); + $admin_permissions[] = 'anonymize other users data'; + $admin_permissions[] = 'administer commerce gdpr'; + $store_admin = $this->drupalCreateUser($admin_permissions); + + $this->drupalLogin($store_admin); + + // Open module settings page. + $this->drupalGet('admin/commerce/config/commerce-gdpr'); + $this->assertResponse(200, t('The module config page opens.')); + + $new_order = commerce_order_new(1); + $product = $this->createDummyProduct('PROD-01', 'Product One'); + $profile = $this->createDummyCustomerProfile('billing', $store_customer->uid); + + variable_set('commerce_gdpr_direct_processing', 1); + + // Check if the anonymize button displays. + $this->drupalGet('user/' . $store_customer->uid); + + // Anonymize the test user using the bulk user form. + $this->drupalPost('admin/people', array(), t('')); + $this->drupalPost(NULL, array(), t('')); + + // Assert if all the fields are anonymized. + + + } + +} diff --git a/commerce_gdpr.user.inc b/commerce_gdpr.user.inc new file mode 100644 index 0000000..02d33d0 --- /dev/null +++ b/commerce_gdpr.user.inc @@ -0,0 +1,310 @@ +uid == $GLOBALS['user']->uid || user_access('anonymize other users data')) { + + // Check if the user is already anonymized or has been scheduled + // for anonymization. + $last_access = _commerce_gdpr_get_info('user', $account->uid); + if ($last_access === 0) { + drupal_set_message(t('This user account data has been anonymized.')); + } + elseif (!empty($account->data['commerce_gdpr_anonymization'])) { + drupal_set_message(t('This account has been scheduled for anonymization.'), 'warning'); + } + else { + $account->content['gdpr_widget'] = drupal_get_form('commerce_gdpr_user_form', $account->uid); + $account->content['gdpr_widget']['#weight'] = 999; + } + } +} + +/** + * Form builder for gdpr widget. + */ +function commerce_gdpr_user_form($form, &$form_state, $uid) { + $form_state['uid'] = $uid; + + ctools_include('modal'); + ctools_modal_add_js(); + drupal_add_js(array( + 'commerce-gdpr-modal' => array( + 'modalSize' => array( + 'type' => 'fixed', + 'width' => 300, + 'height' => 300, + ), + 'animation' => 'fadeIn', + ), + ), 'setting'); + + $form['invoke_confirm'] = array( + '#type' => 'submit', + '#value' => variable_get('commerce_gdpr_user_button_text', 'I want to be forgotten'), + '#ajax' => array( + 'callback' => 'commerce_gdpr_user_ajax', + ), + '#attributes' => array( + 'class' => array('ctools-modal-commerce-gdpr-modal'), + ), + ); + + return $form; +} + +/** + * Modal form builder. + */ +function commerce_gdpr_modal_form($form, &$form_state) { + + $orders = commerce_order_load_multiple(array(), array('uid' => $form_state['uid'])); + $pending = 0; + foreach ($orders as $order) { + $status = commerce_order_status_load($order->status); + if ($status['state'] == 'pending') { + $pending++; + } + } + + if ($pending) { + $form['message'] = array( + '#markup' => t( + 'Warning: we detected you still have !n_orders. We will be unable to process those orders after your data will be anonymized. Please review your !orders_link. Do you still wish to continue?', + array( + '!n_orders' => format_plural($pending, '1 pending order', '!n pending orders', array('!n' => $pending)), + '!orders_link' => l(t('orders'), 'user/' . $form_state['uid'] . '/orders'), + ) + ), + ); + } + else { + if ($form_state['uid'] == $GLOBALS['user']->uid) { + $form['message'] = array( + '#markup' => t('This will anonymize all your user, order and customer profile data so it will only be possible to use for statistical purposes. It will also make your account inactive and log you out of the site instantly. Are you sure?'), + ); + } + else { + $form['message'] = array( + '#markup' => t('Are you sure you wish to anonymize data of this user?'), + ); + } + } + + $form['actions'] = array( + '#type' => 'actions', + ); + $form['actions']['back'] = array( + '#type' => 'submit', + '#value' => t('Take me back'), + '#ajax' => array( + 'callback' => 'commerce_gdpr_user_ajax', + ), + ); + $form['actions']['anonymize'] = array( + '#type' => 'submit', + '#value' => t("Yes, let's do it"), + '#ajax' => array( + 'callback' => 'commerce_gdpr_user_ajax', + ), + ); + return $form; +} + +/** + * Ajax callback for the user GDPR form. + */ +function commerce_gdpr_user_ajax($form, $form_state) { + + ctools_include('ajax'); + ctools_include('modal'); + + $commands = array(); + $trigger = end($form_state['triggering_element']['#parents']); + if ($trigger === 'back' || $trigger === 'anonymize') { + $commands[] = ctools_modal_command_dismiss(); + if ($trigger === 'back') { + $messages = theme('status_messages'); + $commands[] = ajax_command_prepend($form_state['status_wrapper_id'], $messages); + } + else { + $commands[] = ctools_ajax_command_redirect(''); + } + } + elseif ($trigger === 'invoke_confirm') { + $form_state = array( + 'ajax' => TRUE, + 'title' => t('Please confirm'), + 'uid' => $form_state['build_info']['args'][0], + 'status_wrapper_id' => '#' . $form['#id'], + ); + + $commands = ctools_modal_form_wrapper('commerce_gdpr_modal_form', $form_state); + } + + return array('#type' => 'ajax', '#commands' => $commands); +} + +/** + * User GDPR form submit handler. + */ +function commerce_gdpr_modal_form_submit($form, &$form_state) { + $trigger = end($form_state['triggering_element']['#parents']); + + if ($trigger === 'anonymize') { + + $account = user_load($form_state['uid']); + + $queue_items = _commerce_gdpr_get_user_queue_items($account); + + if (variable_get('commerce_gdpr_direct_processing', FALSE)) { + foreach ($queue_items as $item) { + commerce_gdpr_anonymization_worker($item); + } + } + else { + $queue = DrupalQueue::get('commerce_gdpr_anonymization'); + foreach ($queue_items as $item) { + $queue->createItem($item); + } + } + + // Logout user if the current user is anonymizing his own account. + if ($form_state['uid'] == $GLOBALS['user']->uid && $form_state['uid'] != 1) { + $account = user_load($form_state['uid']); + $account->status = 0; + module_invoke_all('user_logout', $account); + session_destroy(); + + $form_state['redirect'] = ''; + if (variable_get('commerce_gdpr_direct_processing', FALSE)) { + drupal_set_message(t('Your account data has been anonymized.')); + } + else { + drupal_set_message(t('Your account data has been scheduled for anonymization.')); + } + } + else { + if (variable_get('commerce_gdpr_direct_processing', FALSE)) { + drupal_set_message(t('Account data has been anonymized.')); + } + else { + drupal_set_message(t('Account data has been scheduled for anonymization.')); + } + } + + $account->data['commerce_gdpr_anonymization'] = TRUE; + user_save($account); + } +} + +/** + * Router function for bulk anonymize operation. + */ +function commerce_gdpr_user_operations_anonymize_router($accounts) { + $_SESSION['commerce_gdpr_bulk_anonymize'] = $accounts; + drupal_goto('admin/people/commerce-gdpr-confirm', array('query' => drupal_get_destination())); +} + +/** + * Bulk anonymization confirmation form builder. + */ +function commerce_gdpr_user_bulk_confirm_form($form, &$form_state) { + $accounts = user_load_multiple($_SESSION['commerce_gdpr_bulk_anonymize']); + $to_anonymize = array(); + $already_done = array(); + foreach ($accounts as $account) { + if (empty($account->anonymized)) { + $to_anonymize[] = $account; + } + else { + $already_done[] = $account; + } + } + + if (!empty($to_anonymize)) { + $form['to_anonymize'] = array( + '#theme' => 'item_list', + '#title' => t("Accounts subject to anonymization"), + '#items' => array(), + ); + foreach ($to_anonymize as $account) { + $form['to_anonymize']['#items'][] = $account->name; + } + } + if (!empty($already_done)) { + $form['already_done'] = array( + '#theme' => 'item_list', + '#title' => t("Accounts already anonymized"), + '#items' => array(), + ); + foreach ($already_done as $account) { + $form['already_done']['#items'][] = $account->name; + } + } + + $form['actions'] = array('#type' => 'actions'); + $form['actions']['back'] = array( + '#type' => 'submit', + '#value' => t('Back'), + ); + if (!empty($to_anonymize)) { + $form['actions']['anonymize'] = array( + '#type' => 'submit', + '#value' => t('Anonymize accounts'), + ); + $form_state['_accounts'] = array(); + foreach ($to_anonymize as $account) { + $form_state['_accounts'][] = $account->uid; + } + } + + return $form; +} + +/** + * Bulk anonymize users operation submit handler. + */ +function commerce_gdpr_user_bulk_confirm_form_submit($form, &$form_state) { + unset($_SESSION['commerce_gdpr_bulk_anonymize']); + + $op = end($form_state['triggering_element']['#parents']); + if ($op !== 'anonymize') { + return; + } + + $accounts = user_load_multiple($form_state['_accounts']); + $direct_processing = variable_get('commerce_gdpr_direct_processing', FALSE); + if ($direct_processing) { + $queue = DrupalQueue::get('commerce_gdpr_anonymization'); + } + + foreach ($accounts as $account) { + if (empty($account->anonymized)) { + $queue_items = _commerce_gdpr_get_user_queue_items($account); + if ($direct_processing) { + foreach ($queue_items as $item) { + commerce_gdpr_anonymization_worker($item); + } + } + else { + foreach ($queue_items as $item) { + $queue->createItem($item); + } + } + } + } + if ($direct_processing) { + drupal_set_message(format_plural(count($accounts), '1 account has been anonymized.', '@count accounts have been anonymized.')); + } + else { + drupal_set_message(format_plural(count($accounts), '1 account has been scheduled for anonymization.', '@count accounts have been scheduled for anonymization.')); + } +} From a0920222644d6e86d095a67ab3c8775b90e73d58 Mon Sep 17 00:00:00 2001 From: Marcin Grabias Date: Thu, 22 Mar 2018 15:35:53 +0100 Subject: [PATCH 2/5] Update to latest. --- commerce_gdpr.test | 53 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/commerce_gdpr.test b/commerce_gdpr.test index 419b380..fc98b3f 100644 --- a/commerce_gdpr.test +++ b/commerce_gdpr.test @@ -157,7 +157,7 @@ class CommerceGdprFunctionalTestCase extends CommerceBaseTestCase { public function testUserAnonymization() { $store_customer = $this->createStoreCustomer(); - $admin_permissions = $this->permissionBuilder('store admin'); + $admin_permissions = $this->permissionBuilder(array('site admin', 'store admin')); $admin_permissions[] = 'anonymize other users data'; $admin_permissions[] = 'administer commerce gdpr'; $store_admin = $this->drupalCreateUser($admin_permissions); @@ -168,22 +168,65 @@ class CommerceGdprFunctionalTestCase extends CommerceBaseTestCase { $this->drupalGet('admin/commerce/config/commerce-gdpr'); $this->assertResponse(200, t('The module config page opens.')); - $new_order = commerce_order_new(1); + $order = commerce_order_new($store_customer->uid); $product = $this->createDummyProduct('PROD-01', 'Product One'); $profile = $this->createDummyCustomerProfile('billing', $store_customer->uid); + $order->commerce_line_items = array( + LANGUAGE_NONE => array( + array('line_item_id' => $product->product_id), + ), + ); + $order->commerce_customer_billing = array( + LANGUAGE_NONE => array( + array('profile_id' => $profile->profile_id), + ), + ); + $order->mail = 'example@example.com'; + $order->status = 'completed'; + commerce_order_save($order); + // Test with direct processing. variable_set('commerce_gdpr_direct_processing', 1); + // Set user profile field. + variable_set('commerce_gdpr_anonymized_fields', array( + 'commerce_customer_profile' => array( + 'billing' => array( + 'commerce_customer_address' => 'commerce_customer_address', + ), + ), + )); + // Check if the anonymize button displays. $this->drupalGet('user/' . $store_customer->uid); + $this->assertField('edit-invoke-confirm', t('Anonymize account button exists')); // Anonymize the test user using the bulk user form. - $this->drupalPost('admin/people', array(), t('')); - $this->drupalPost(NULL, array(), t('')); + $this->drupalPost('admin/people', array( + 'operation' => 'anonymize', + 'accounts[' . $store_customer->uid . ']' => $store_customer->uid, + ), t('Update')); + $this->drupalPost(NULL, array(), t('Anonymize accounts')); // Assert if all the fields are anonymized. - + $anonymized_order = commerce_order_load($order->order_id); + $expected = commerce_gdpr_anonymize_value($order->mail, array( + 'type' => 'hash', + )); + $this->assertEqual($anonymized_order->mail, $expected, t('Order email property has been anonymized')); + + $anonymized_profile = commerce_customer_profile_load($profile->profile_id); + $raw_value = $profile->commerce_customer_address[LANGUAGE_NONE][0]['name_line']; + $expected = commerce_gdpr_anonymize_value($raw_value, array( + 'type' => 'hash', + )); + $this->assertEqual($anonymized_profile->commerce_customer_address[LANGUAGE_NONE][0]['name_line'], $expected, t('Profile name field has been anonymized')); + $anonymized_user = user_load($store_customer->uid); + $expected = commerce_gdpr_anonymize_value($store_customer->mail, array( + 'type' => 'hash', + )); + $this->assertEqual($anonymized_user->mail, $expected, t('User e-mail property has been anonymized')); } } From 9013dc29b8ab1be4d4f667f42b296dae2f3260fa Mon Sep 17 00:00:00 2001 From: Marcin Grabias Date: Wed, 28 Mar 2018 15:23:37 +0200 Subject: [PATCH 3/5] Update to d.o. latest. --- README.txt | 8 ++++- commerce_gdpr.admin.inc | 20 ++++++++++-- commerce_gdpr.install | 71 +++++++++++++++++++++++++++++++++++++++++ commerce_gdpr.module | 58 +++++++++++++++++++++++++++++++-- commerce_gdpr.test | 54 ++++++++++++++++++------------- 5 files changed, 182 insertions(+), 29 deletions(-) diff --git a/README.txt b/README.txt index 9c66635..a1e1341 100644 --- a/README.txt +++ b/README.txt @@ -72,10 +72,16 @@ otherwise comparing data from before and after the change will be impossible. 2. Admin UI +To setup the module, go to /admin/commerce/config/commerce-gdpr. + +If automatic anonymization is required, set Order data retention period to +a positive integer value. Also check roles that shouldn't be automatically +anonymized. + By default, only entity properties are anonimized and automatic anonimization is switched off. To select entity felds that should also be anonimized and time after automatic anonymization should take place, go to the module -configuration form (/admin/commerce/config/commerce-gdpr). +configuration form (). 3. Custom entity properties diff --git a/commerce_gdpr.admin.inc b/commerce_gdpr.admin.inc index 7115e01..c715abd 100644 --- a/commerce_gdpr.admin.inc +++ b/commerce_gdpr.admin.inc @@ -26,6 +26,17 @@ function commerce_gdpr_admin_form($form, &$form_state) { '#description' => t('Enter number of days after which inactive data will be anonymized, 0 - no automatic anonymization.'), ); + $roles = user_roles(TRUE); + if (!empty($roles)) { + $form['excluded_roles'] = array( + '#type' => 'checkboxes', + '#options' => user_roles(TRUE), + '#title' => t('Excluded roles'), + '#default_value' => variable_get('commerce_gdpr_excluded_roles', array()), + '#description' => t('Select which roles should not be anonymized automatically (If data retention period > 0).'), + ); + } + $form['direct_processing'] = array( '#type' => 'checkbox', '#title' => t('Process items immediately'), @@ -110,8 +121,8 @@ function commerce_gdpr_admin_form($form, &$form_state) { * Admin form validate handler. */ function commerce_gdpr_admin_form_validate($form, &$form_state) { - if (!is_numeric($form_state['values']['data_retention'])) { - form_set_error('data_retention', t('Enter a valid data retention value.')); + if (!ctype_digit($form_state['values']['data_retention'])) { + form_set_error('data_retention', t('Enter a valid data retention value in days.')); } } @@ -119,7 +130,10 @@ function commerce_gdpr_admin_form_validate($form, &$form_state) { * Admin form submit handler. */ function commerce_gdpr_admin_form_submit($form, &$form_state) { - variable_set('commerce_gdpr_data_retention', $form_state['values']['data_retention']); + $last_retention = variable_get('commerce_gdpr_data_retention', 0); + + variable_set('commerce_gdpr_data_retention', intval($form_state['values']['data_retention'])); + variable_set('commerce_gdpr_excluded_roles', array_filter($form_state['values']['excluded_roles'])); variable_set('commerce_gdpr_direct_processing', $form_state['values']['direct_processing']); variable_set('commerce_gdpr_user_button_text', $form_state['values']['user_button_text']); diff --git a/commerce_gdpr.install b/commerce_gdpr.install index 31a3de7..c8543f2 100644 --- a/commerce_gdpr.install +++ b/commerce_gdpr.install @@ -40,3 +40,74 @@ function commerce_gdpr_schema() { return $schema; } + +/** + * Implements hook_enable(). + */ +function commerce_gdpr_enable() { + // Update last access data for users so users created + // when the module was disabled are also considered. + $query = db_select('users', 'u')->fields('u', array('uid', 'access'))->condition('uid', 1, '>'); + $total = $query->countQuery()->execute()->fetchField(); + + $batch_size = 10; + $queue = DrupalQueue::get('commerce_gdpr_update_access'); + for ($offset = 0; $offset < $total; $offset += $batch_size) { + $queue->createItem(array( + 'offset' => $offset, + 'limit' => $batch_size, + )); + } +} + +/** + * Add users created before the module was enabled to the last_access table. + */ +function commerce_gdpr_update_7101(&$sandbox) { + $query = db_select('users', 'u')->fields('u', array('uid', 'access'))->condition('uid', 1, '>'); + if (!isset($sandbox['total'])) { + $sandbox['total'] = $query->countQuery()->execute()->fetchField(); + $sandbox['delta'] = 0; + $sandbox['operations'] = array(); + } + + // Process in 10 items batches. + $query->orderBy('uid', 'ASC'); + $query->range($sandbox['delta'], 10); + $results = $query->execute()->fetchAllKeyed(); + + foreach ($results as $uid => $last_access) { + // If this uid is already in the commerce_gdpr_access table - skip. + if (!db_select('commerce_gdpr_access', 'c')->fields('c', array('id'))->condition('type', 'user')->condition('id', $uid)->execute()->fetchField()) { + + // Check if user orders and profiles haven't been updated after + // last access of the user. + $orders = commerce_order_load_multiple(array(), array('uid' => $uid)); + foreach ($orders as $order) { + if ($order->changed > $last_access) { + $last_access = $order->changed; + } + } + $profiles = commerce_customer_profile_load_multiple(array(), array('uid' => $uid)); + foreach ($profiles as $profile) { + if ($profile->changed > $last_access) { + $last_access = $profile->changed; + } + } + + // User has no orders and never accessed the site. + if (!$last_access) { + $last_access = db_select('users', 'u')->fields('u', array('created'))->condition('uid', $uid)->execute()->fetchField(); + } + + db_insert('commerce_gdpr_access')->fields(array( + 'type' => 'user', + 'id' => $uid, + 'last_access' => $last_access, + ))->execute(); + } + + $sandbox['delta']++; + $sandbox['finished'] = $sandbox['delta'] / $sandbox['total']; + } +} diff --git a/commerce_gdpr.module b/commerce_gdpr.module index 11489aa..d25ff86 100644 --- a/commerce_gdpr.module +++ b/commerce_gdpr.module @@ -635,11 +635,15 @@ function commerce_gdpr_cron_queue_info() { 'worker callback' => 'commerce_gdpr_anonymization_worker', 'time' => 30, ); + $queues['commerce_gdpr_update_access'] = array( + 'worker callback' => 'commerce_gdpr_update_access_worker', + 'time' => 30, + ); return $queues; } /** - * Queue worker callback. + * Anonymization queue worker callback. */ function commerce_gdpr_anonymization_worker($item) { @@ -669,6 +673,48 @@ function commerce_gdpr_anonymization_worker($item) { } } +/** + * Last access update queue worker. + */ +function commerce_gdpr_update_access_worker($item) { + $query = db_select('users', 'u')->fields('u', array('uid', 'access'))->condition('uid', 1, '>'); + $query->orderBy('uid', 'ASC'); + $query->range($item['offset'], $item['limit']); + $results = $query->execute()->fetchAllKeyed(); + + foreach ($results as $uid => $last_access) { + // If this uid is already in the commerce_gdpr_access table - skip. + if (!db_select('commerce_gdpr_access', 'c')->fields('c', array('id'))->condition('type', 'user')->condition('id', $uid)->execute()->fetchField()) { + + // Check if user orders and profiles haven't been updated after + // last access of the user. + $orders = commerce_order_load_multiple(array(), array('uid' => $uid)); + foreach ($orders as $order) { + if ($order->changed > $last_access) { + $last_access = $order->changed; + } + } + $profiles = commerce_customer_profile_load_multiple(array(), array('uid' => $uid)); + foreach ($profiles as $profile) { + if ($profile->changed > $last_access) { + $last_access = $profile->changed; + } + } + + // User has no orders and never accessed the site. + if (!$last_access) { + $last_access = db_select('users', 'u')->fields('u', array('created'))->condition('uid', $uid)->execute()->fetchField(); + } + + db_insert('commerce_gdpr_access')->fields(array( + 'type' => 'user', + 'id' => $uid, + 'last_access' => $last_access, + ))->execute(); + } + } +} + /** * Implements hook_cron(). */ @@ -693,10 +739,18 @@ function commerce_gdpr_cron() { return; } - // Add items to be processed in the next cron queue. + // Add items to be processed in the next cron queue if they shouldn't + // be skipped. + $excluded_roles = variable_get('commerce_gdpr_excluded_roles', array()); foreach ($results as $item) { if ($item['type'] === 'user') { $account = user_load($item['id']); + foreach ($account->roles as $rid => $role_name) { + if (in_array($rid, $excluded_roles)) { + continue 2; + } + } + $items = _commerce_gdpr_get_user_queue_items($account); foreach ($items as $item) { $queue->createItem($item); diff --git a/commerce_gdpr.test b/commerce_gdpr.test index fc98b3f..9e041fc 100644 --- a/commerce_gdpr.test +++ b/commerce_gdpr.test @@ -86,14 +86,27 @@ class CommerceGdprFunctionalTestCase extends CommerceBaseTestCase { /** * {@inheritdoc} */ - public function setUp() { - // We call parent::setUp() with the list of modules we want to enable. - $modules = parent::setUpHelper('all'); - $modules[] = 'commerce_gdpr'; + protected function setUpHelper($set = 'all', array $other_modules = array()) { + $modules = parent::setUpHelper($set, $other_modules); parent::setUp($modules); - $GLOBALS['drupal_hash_salt'] = hash('md5', mt_rand()); + + $this->store_customer = $this->createStoreCustomer(); + $admin_permissions = $this->permissionBuilder(array('site admin', 'store admin')); + $admin_permissions[] = 'anonymize other users data'; + $admin_permissions[] = 'administer commerce gdpr'; + $this->store_admin = $this->drupalCreateUser($admin_permissions); + } + + /** + * {@inheritdoc} + */ + public function setUp() { + $this->setUpHelper('all', array('commerce_gdpr')); + $this->example_value = 'example value'; $this->expected = hash_hmac('md5', $this->example_value, $GLOBALS['drupal_hash_salt']); + + $this->drupalLogin($this->store_admin); } /** @@ -155,22 +168,13 @@ class CommerceGdprFunctionalTestCase extends CommerceBaseTestCase { * Test the user anonymization path. */ public function testUserAnonymization() { - $store_customer = $this->createStoreCustomer(); - - $admin_permissions = $this->permissionBuilder(array('site admin', 'store admin')); - $admin_permissions[] = 'anonymize other users data'; - $admin_permissions[] = 'administer commerce gdpr'; - $store_admin = $this->drupalCreateUser($admin_permissions); - - $this->drupalLogin($store_admin); - // Open module settings page. $this->drupalGet('admin/commerce/config/commerce-gdpr'); $this->assertResponse(200, t('The module config page opens.')); - $order = commerce_order_new($store_customer->uid); + $order = commerce_order_new($this->store_customer->uid); $product = $this->createDummyProduct('PROD-01', 'Product One'); - $profile = $this->createDummyCustomerProfile('billing', $store_customer->uid); + $profile = $this->createDummyCustomerProfile('billing', $this->store_customer->uid); $order->commerce_line_items = array( LANGUAGE_NONE => array( array('line_item_id' => $product->product_id), @@ -198,32 +202,36 @@ class CommerceGdprFunctionalTestCase extends CommerceBaseTestCase { )); // Check if the anonymize button displays. - $this->drupalGet('user/' . $store_customer->uid); + $this->drupalGet('user/' . $this->store_customer->uid); $this->assertField('edit-invoke-confirm', t('Anonymize account button exists')); // Anonymize the test user using the bulk user form. - $this->drupalPost('admin/people', array( + $output = $this->drupalPost('admin/people', array( 'operation' => 'anonymize', - 'accounts[' . $store_customer->uid . ']' => $store_customer->uid, + 'accounts[' . $this->store_customer->uid . ']' => $this->store_customer->uid, ), t('Update')); + $this->drupalPost(NULL, array(), t('Anonymize accounts')); + $this->drupalGet('admin/people'); // Assert if all the fields are anonymized. - $anonymized_order = commerce_order_load($order->order_id); + $entities = commerce_order_load_multiple(array($order->order_id), array(), TRUE); + $anonymized_order = reset($entities); $expected = commerce_gdpr_anonymize_value($order->mail, array( 'type' => 'hash', )); $this->assertEqual($anonymized_order->mail, $expected, t('Order email property has been anonymized')); - $anonymized_profile = commerce_customer_profile_load($profile->profile_id); + $entities = commerce_customer_profile_load_multiple(array($profile->profile_id), array(), TRUE); + $anonymized_profile = reset($entities); $raw_value = $profile->commerce_customer_address[LANGUAGE_NONE][0]['name_line']; $expected = commerce_gdpr_anonymize_value($raw_value, array( 'type' => 'hash', )); $this->assertEqual($anonymized_profile->commerce_customer_address[LANGUAGE_NONE][0]['name_line'], $expected, t('Profile name field has been anonymized')); - $anonymized_user = user_load($store_customer->uid); - $expected = commerce_gdpr_anonymize_value($store_customer->mail, array( + $anonymized_user = user_load($this->store_customer->uid, TRUE); + $expected = commerce_gdpr_anonymize_value($this->store_customer->mail, array( 'type' => 'hash', )); $this->assertEqual($anonymized_user->mail, $expected, t('User e-mail property has been anonymized')); From 1bea52f7e5f49d606b2b691f391c59413cf73f8b Mon Sep 17 00:00:00 2001 From: Marcin Grabias Date: Thu, 29 Mar 2018 12:43:09 +0200 Subject: [PATCH 4/5] Updated to d.o. latest. --- commerce_gdpr.admin.inc | 6 ++++-- commerce_gdpr.install | 12 +----------- commerce_gdpr.module | 33 ++++++++++++++++++++++++++++----- 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/commerce_gdpr.admin.inc b/commerce_gdpr.admin.inc index c715abd..8a01ebf 100644 --- a/commerce_gdpr.admin.inc +++ b/commerce_gdpr.admin.inc @@ -130,8 +130,6 @@ function commerce_gdpr_admin_form_validate($form, &$form_state) { * Admin form submit handler. */ function commerce_gdpr_admin_form_submit($form, &$form_state) { - $last_retention = variable_get('commerce_gdpr_data_retention', 0); - variable_set('commerce_gdpr_data_retention', intval($form_state['values']['data_retention'])); variable_set('commerce_gdpr_excluded_roles', array_filter($form_state['values']['excluded_roles'])); variable_set('commerce_gdpr_direct_processing', $form_state['values']['direct_processing']); @@ -149,5 +147,9 @@ function commerce_gdpr_admin_form_submit($form, &$form_state) { } variable_set('commerce_gdpr_anonymized_fields', $selected_fields); + if (variable_get('commerce_gdpr_excluded_roles', array()) !== $form['excluded_roles']['#default_value']) { + _commerce_gdpr_update_last_access(); + } + drupal_set_message(t('Settings saved.')); } diff --git a/commerce_gdpr.install b/commerce_gdpr.install index c8543f2..b33e5ff 100644 --- a/commerce_gdpr.install +++ b/commerce_gdpr.install @@ -47,17 +47,7 @@ function commerce_gdpr_schema() { function commerce_gdpr_enable() { // Update last access data for users so users created // when the module was disabled are also considered. - $query = db_select('users', 'u')->fields('u', array('uid', 'access'))->condition('uid', 1, '>'); - $total = $query->countQuery()->execute()->fetchField(); - - $batch_size = 10; - $queue = DrupalQueue::get('commerce_gdpr_update_access'); - for ($offset = 0; $offset < $total; $offset += $batch_size) { - $queue->createItem(array( - 'offset' => $offset, - 'limit' => $batch_size, - )); - } + _commerce_gdpr_update_last_access(); } /** diff --git a/commerce_gdpr.module b/commerce_gdpr.module index d25ff86..e1609f7 100644 --- a/commerce_gdpr.module +++ b/commerce_gdpr.module @@ -363,7 +363,7 @@ function _commerce_gdpr_get_user_queue_items($account) { $queue_items[] = array( 'type' => 'commerce_order', 'id' => $order->order_id, - 'uid' => $uid, + 'owner_uid' => $uid, ); } } @@ -375,7 +375,7 @@ function _commerce_gdpr_get_user_queue_items($account) { $queue_items[] = array( 'type' => 'commerce_customer_profile', 'id' => $profile->profile_id, - 'uid' => $uid, + 'owner_uid' => $uid, ); } } @@ -627,6 +627,27 @@ function commerce_gdpr_user_operations() { } } +/** + * Register update last access items. + */ +function _commerce_gdpr_update_last_access() { + // TODO: This can be optimized by using the excluded roles. + $query = db_select('users', 'u')->fields('u', array('uid', 'access'))->condition('uid', 1, '>'); + $total = $query->countQuery()->execute()->fetchField(); + + $batch_size = 10; + $queue = DrupalQueue::get('commerce_gdpr_update_access'); + // If there are items in this queue - delete them. + $queue->deleteQueue(); + + for ($offset = 0; $offset < $total; $offset += $batch_size) { + $queue->createItem(array( + 'offset' => $offset, + 'limit' => $batch_size, + )); + } +} + /** * Implements hook_cron_queue_info(). */ @@ -657,15 +678,15 @@ function commerce_gdpr_anonymization_worker($item) { commerce_gdpr_anonymize_entity($item['type'], $entity); // Optionally change ownership of the entity. - if (!empty($item['uid'])) { + if (!empty($item['owner_uid'])) { if ($item['type'] === 'commerce_customer_profile') { db_update('commerce_customer_profile') - ->fields(array('uid' => $item['uid'])) + ->fields(array('uid' => $item['owner_uid'])) ->condition('profile_id', $entity->profile_id) ->execute(); } else { - $entity->uid = $item['uid']; + $entity->uid = $item['owner_uid']; entity_metadata_wrapper($item['type'], $entity)->save(); } } @@ -747,6 +768,8 @@ function commerce_gdpr_cron() { $account = user_load($item['id']); foreach ($account->roles as $rid => $role_name) { if (in_array($rid, $excluded_roles)) { + // Prevent reprocessing the user in next cron runs and continue. + db_delete('commerce_gdpr_access')->condition('type', 'user')->condition('id', $item['id'])->execute(); continue 2; } } From 66a8aa8f89dc5e77ea397b99b4aee6a637f495b1 Mon Sep 17 00:00:00 2001 From: torgospizza Date: Mon, 18 Jun 2018 11:54:10 +0200 Subject: [PATCH 5/5] Issue #2975131 by torgosPizza: Exclude roles in _commerce_gdpr_update_last_access() --- commerce_gdpr.module | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/commerce_gdpr.module b/commerce_gdpr.module index e1609f7..0dd92e5 100644 --- a/commerce_gdpr.module +++ b/commerce_gdpr.module @@ -631,8 +631,15 @@ function commerce_gdpr_user_operations() { * Register update last access items. */ function _commerce_gdpr_update_last_access() { - // TODO: This can be optimized by using the excluded roles. - $query = db_select('users', 'u')->fields('u', array('uid', 'access'))->condition('uid', 1, '>'); + $query = db_select('users', 'u')->fields('u', array('uid', 'access'))->condition('u.uid', 1, '>'); + + // If any roles are excluded, omit those users from our results. + $roles = variable_get('commerce_gdpr_excluded_roles', array()); + if (!empty($roles)) { + $query->join('users_roles', 'ur', 'ur.uid = u.uid'); + $query->condition('ur.rid', array_keys($roles), 'NOT IN'); + } + $total = $query->countQuery()->execute()->fetchField(); $batch_size = 10;