diff --git a/composer.json b/composer.json index 795f046..1b7150e 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ } ], "require": { - "drupal/checklistapi": "^1.0" + "drupal/checklistapi": "^1.0", + "drupal/views_data_export": "^1.0@alpha" } } diff --git a/modules/gdpr_view_export_log/gdpr_view_export_log.info.yml b/modules/gdpr_view_export_log/gdpr_view_export_log.info.yml new file mode 100644 index 0000000..76b2bdf --- /dev/null +++ b/modules/gdpr_view_export_log/gdpr_view_export_log.info.yml @@ -0,0 +1,10 @@ +name: General Data Protection Regulation (GDPR) - Log View Exports +description: Logs view exports +core: 8.x +type: module +package: General Data Protection Regulation + +dependencies: + - gdpr:gdpr + - views_data_export:views_data_export + - rules:rules diff --git a/modules/gdpr_view_export_log/gdpr_view_export_log.install b/modules/gdpr_view_export_log/gdpr_view_export_log.install new file mode 100644 index 0000000..745d618 --- /dev/null +++ b/modules/gdpr_view_export_log/gdpr_view_export_log.install @@ -0,0 +1,74 @@ +getEditable('views.settings'); + $display_extenders = $config->get('display_extenders') ?: []; + $display_extenders[] = 'gdpr_view_export_logging'; + $config->set('display_extenders', $display_extenders); + $config->save(); +} + +/** + * Implements hook_uninstall(). + */ +function gdpr_view_export_log_uninstall() { + $config = \Drupal::service('config.factory')->getEditable('views.settings'); + $display_extenders = $config->get('display_extenders') ?: []; + $key = array_search('gdpr_view_export_logging', $display_extenders); + if ($key !== FALSE) { + unset($display_extenders[$key]); + $config->set('display_extenders', $display_extenders); + $config->save(); + } +} + +/** + * Implements hook_schema(). + * + * User IDs are stored in a custom table rather than an entity field. + * This is so that they can be lazily loaded for performance reasons, + * as we don't want to be loading thousands of users when looking up a log. + */ +function gdpr_view_export_log_schema() { + $schema['gdpr_view_export_audit_user_ids'] = [ + 'description' => 'Stores when Merge Processes are happening on an entity.', + 'fields' => [ + 'id' => [ + 'type' => 'serial', + 'description' => 'Unique Identifier.', + 'unsigned' => TRUE, + 'not null' => TRUE, + ], + 'log_id' => [ + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => 'The entity id of the corresponding log entry.', + ], + 'user_id' => [ + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => 'The ID of the user.', + ], + 'name' => [ + 'type' => 'varchar', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + 'description' => 'Cache of the user\'s name', + ], + ], + 'primary key' => ['id'], + ]; + + return $schema; +} diff --git a/modules/gdpr_view_export_log/gdpr_view_export_log.module b/modules/gdpr_view_export_log/gdpr_view_export_log.module new file mode 100644 index 0000000..0b05880 --- /dev/null +++ b/modules/gdpr_view_export_log/gdpr_view_export_log.module @@ -0,0 +1,138 @@ +hasPermission('create gdpr export audits')) { + $items['gdpr']['tray']['links']['#links']['exports'] = [ + 'title' => t('Exports'), + 'url' => Url::fromRoute('entity.gdpr_view_export_audit.collection'), + 'attributes' => [ + 'title' => t('Exports'), + ], + 'weight' => 100, + ]; + } +} + +/** + * Implements hook_views_post_build(). + */ +function gdpr_view_export_log_views_post_build(ViewExecutable $view) { + if (GdprExportLogDisplayExtender::isLoggingEnabled($view)) { + // Logging is enabled for this view. + // Instead of just letting it render, redirect to the audit page, + // if we haven't been there already. + $already_audited = \Drupal::request() + ->getSession() + ->get('gdpr_audit_id') > 0; + + if (!$already_audited) { + + // @todo: Do not run in preview mode. + $session = \Drupal::request()->getSession(); + + $session->set('gdpr_export_audit_file', $view->display_handler->options["filename"]); + $session->set('gdpr_export_audit_view', $view->id()); + $session->set('gdpr_export_audit_continue', \Drupal::request() + ->getRequestUri()); + + $url = Url::fromRoute('entity.gdpr_view_export_audit.add_form') + ->toString(); + $response = new RedirectResponse($url); + $response->send(); + } + } +} + +/** + * Implements hook_views_post_execute(). + */ +function gdpr_view_export_log_views_post_execute(ViewExecutable $view) { + // After the view is executed, if there has been an audit entry recorded, + // Modify the audit to store any user IDs included in the output. + if (GdprExportLogDisplayExtender::isLoggingEnabled($view)) { + $session = \Drupal::request()->getSession(); + $audit_entry_id = $session->get('gdpr_audit_id'); + + $value_accessors = []; + + if ($audit_entry_id > 0) { + $audit_entry = ExportAudit::load($audit_entry_id); + + // fieldDefinition is unfortunately protected. + // Use reflection to get it for now. + // @todo look at not using reflection in future + $r = new ReflectionMethod('Drupal\views\Plugin\views\field\EntityField', 'getFieldDefinition'); + $r->setAccessible(TRUE); + + // Are any of the fields defined against user? + foreach ($view->field as $field_id => $field) { + // Only process entity fields. + if ($field->definition['class'] == 'Drupal\views\Plugin\views\field\EntityField') { + + // If the field is directly defined on the user, log the ID. + if ($field->definition['entity_type'] == 'user') { + $value_accessors[] = function ($row) use ($field) { + return $field->getEntity($row)->id(); + }; + } + + $field_definition = $r->invoke($field); + + // If the field is a reference to the user, log the id. + if ($field_definition->getType() == 'entity_reference' && $field_definition->getSetting('target_type') == 'user') { + $value_accessors[] = function ($row) use ($field) { + return $field->getValue($row); + }; + } + } + } + + $ids = []; + + foreach ($view->result as $row) { + foreach ($value_accessors as $accessor) { + $id = $accessor($row); + if (!in_array($id, $ids)) { + $ids[] = $id; + } + } + } + + if (count($ids) > 0) { + $audit_entry->save(); + // User IDs are stored in a custom table rather than an entity field. + // This is so that they can be lazily loaded for performance reasons, + // as we don't want to be loading thousands of users + // when looking up a log entry. + foreach ($ids as $id) { + // @todo cache the user's name in here too? + \Drupal::database()->insert('gdpr_view_export_audit_user_ids') + ->fields([ + 'log_id' => $audit_entry->id(), + 'user_id' => $id, + ])->execute(); + } + } + } + + $session->remove('gdpr_audit_id'); + + } + +} diff --git a/modules/gdpr_view_export_log/gdpr_view_export_log.permissions.yml b/modules/gdpr_view_export_log/gdpr_view_export_log.permissions.yml new file mode 100644 index 0000000..a1a717a --- /dev/null +++ b/modules/gdpr_view_export_log/gdpr_view_export_log.permissions.yml @@ -0,0 +1,2 @@ +create gdpr export audits: + title: 'Create audit entries for exporting views' diff --git a/modules/gdpr_view_export_log/gdpr_view_export_log.routing.yml b/modules/gdpr_view_export_log/gdpr_view_export_log.routing.yml new file mode 100644 index 0000000..cca735d --- /dev/null +++ b/modules/gdpr_view_export_log/gdpr_view_export_log.routing.yml @@ -0,0 +1,21 @@ +gdpr_view_export_log.view_users: + path: '/admin/gdpr/export/{id}/users' + defaults: + _controller: '\Drupal\gdpr_view_export_log\Controller\ExportAuditController::viewUsers' + requirements: + _permission: 'create gdpr export audits' + +gdpr_view_export_log.delete_user: + path: '/admin/gdpr/export/{id}/users/{user_id}/remove' + defaults: + _form: '\Drupal\gdpr_view_export_log\Form\RemoveUserFromExportForm' + requirements: + _permission: 'create gdpr export audits' + +gdpr_view_export_log.exports_containing_user: + path: '/admin/gdpr/exports_containing_user/{user_id}' + defaults: + #_controller: '\Drupal\gdpr_view_export_log\Controller\ExportAuditController::viewExports' + _entity_list: 'gdpr_view_export_audit' + requirements: + _permission: 'create gdpr export audits' diff --git a/modules/gdpr_view_export_log/src/Controller/ExportAuditController.php b/modules/gdpr_view_export_log/src/Controller/ExportAuditController.php new file mode 100644 index 0000000..4b55f58 --- /dev/null +++ b/modules/gdpr_view_export_log/src/Controller/ExportAuditController.php @@ -0,0 +1,93 @@ +select('gdpr_view_export_audit_user_ids', 'g'); + + $count_query = clone $query; + $count_query->addExpression('count(g.id)'); + + $paged_query = $query->extend('Drupal\Core\Database\Query\PagerSelectExtender'); + $paged_query->limit(50); + $paged_query->setCountQuery($count_query); + $paged_query->addJoin('left', 'users_field_data', 'u', 'g.user_id = u.uid'); + + $results = $paged_query->fields('g', ['user_id']) + ->fields('u', ['name']) + ->condition('g.log_id', $id) + ->execute() + ->fetchAll(\PDO::FETCH_ASSOC); + + $output = [ + 'back' => [ + '#type' => 'link', + '#url' => Url::fromRoute('entity.gdpr_view_export_audit.collection'), + '#title' => $this->t('Back to export list'), + ], + + 'table' => [ + '#type' => 'table', + '#header' => ['ID', 'Username', ''], + '#empty' => 'Could not locate any users in this export.', + ], + + 'pager' => [ + '#theme' => 'pager', + '#weight' => 5, + '#element' => 0, + '#parameters' => [], + '#quantity' => 9, + '#route_name' => '', + '#tags' => '', + ], + ]; + + foreach ($results as $result) { + $output['table'][$result['user_id']]['userid'] = [ + '#markup' => $result['user_id'], + ]; + + $output['table'][$result['user_id']]['username'] = [ + '#type' => 'link', + '#url' => Url::fromRoute('entity.user.canonical', ['user' => $result['user_id']]), + '#title' => $result['name'], + ]; + + $output['table'][$result['user_id']]['operations'] = [ + '#type' => 'operations', + '#links' => [ + 'remove' => [ + 'title' => $this->t('Remove'), + 'url' => Url::fromRoute('gdpr_view_export_log.delete_user', [ + 'id' => $id, + 'user_id' => $result['user_id'], + ]), + ], + ], + ]; + } + + return $output; + } + +} diff --git a/modules/gdpr_view_export_log/src/Entity/ExportAudit.php b/modules/gdpr_view_export_log/src/Entity/ExportAudit.php new file mode 100644 index 0000000..255cbcb --- /dev/null +++ b/modules/gdpr_view_export_log/src/Entity/ExportAudit.php @@ -0,0 +1,130 @@ +setLabel(t('Location')) + ->setDescription(t("Where will this export be stored? (For example, a user's PC)")) + ->setRequired(TRUE) + ->setDisplayOptions('form', [ + 'type' => 'textfield', + 'weight' => 1, + ]) + ->setDisplayConfigurable('form', TRUE) + ->setDisplayConfigurable('view', TRUE); + + $fields['reason'] = BaseFieldDefinition::create('string_long') + ->setLabel(t('Reason')) + ->setDescription(t('The reason for the export')) + ->setRequired(TRUE) + ->setDisplayOptions('form', [ + 'type' => 'textfield_long', + 'weight' => 2, + ]) + ->setDisplayConfigurable('form', TRUE) + ->setDisplayConfigurable('view', TRUE); + + $fields['length'] = BaseFieldDefinition::create('integer') + ->setLabel(t('Length')) + ->setDescription(t('Length (in a days) that the export should live for.')) + ->setDefaultValue(90) + ->setRequired(TRUE) + ->setDisplayOptions('form', [ + 'type' => 'number', + 'weight' => 3, + ]) + ->setDisplayConfigurable('form', TRUE) + ->setDisplayConfigurable('view', TRUE); + + $fields['owner'] = BaseFieldDefinition::create('entity_reference') + ->setLabel(t('Authored by')) + ->setDescription(t('The user ID of author of the audit entry.')) + ->setRevisionable(TRUE) + ->setSetting('target_type', 'user') + ->setSetting('handler', 'default') + ->setTranslatable(TRUE) + ->setDisplayOptions('view', [ + 'type' => 'author', + ]) + ->setDisplayOptions('form', [ + 'type' => 'hidden', + 'settings' => [ + 'match_operator' => 'CONTAINS', + 'size' => '60', + 'autocomplete_type' => 'tags', + 'placeholder' => '', + ], + ]) + ->setDisplayConfigurable('form', TRUE) + ->setDisplayConfigurable('view', TRUE); + + $fields['created'] = BaseFieldDefinition::create('created') + ->setLabel(t('Created')) + ->setDescription(t('The time that the entity was created.')); + + $fields['filename'] = BaseFieldDefinition::create('string') + ->setLabel(t('File name')) + ->setDescription(t('The name of the file')); + + $fields['view'] = BaseFieldDefinition::create('string') + ->setLabel(t('View')) + ->setDescription(t('The name of the view')); + + return $fields; + } + +} diff --git a/modules/gdpr_view_export_log/src/Entity/ExportAuditListBuilder.php b/modules/gdpr_view_export_log/src/Entity/ExportAuditListBuilder.php new file mode 100644 index 0000000..07a4470 --- /dev/null +++ b/modules/gdpr_view_export_log/src/Entity/ExportAuditListBuilder.php @@ -0,0 +1,97 @@ + 'File Name', + 'location' => 'Location', + 'reason' => 'Reason', + 'created' => 'Date Exported', + 'expires' => 'Expires In', + 'owner' => 'Exported By', + ] + parent::buildHeader(); + } + + /** + * {@inheritdoc} + */ + protected function getEntityIds() { + // If a user ID was passed in the route then only load the exports + // that contain the specified user ID. + $user_id = \Drupal::routeMatch()->getParameter('user_id'); + if ($user_id) { + $query = $this->getStorage()->getQuery() + ->condition('user_ids', $user_id) + ->sort($this->entityType->getKey('id')); + + // Only add the pager if a limit is specified. + if ($this->limit) { + $query->pager($this->limit); + } + return $query->execute(); + } + else { + return parent::getEntityIds(); + } + } + + /** + * {@inheritdoc} + */ + public function buildRow(EntityInterface $entity) { + $expires = $entity->get('length')->value; + + $date_exported = new \DateTimeImmutable(); + $date_exported = $date_exported->setTimestamp($entity->get('created')->value); + $date_expires = $date_exported->modify("+{$expires} day"); + + $now = new \DateTimeImmutable('now'); + $diff = $now->diff($date_expires); + + $row = [ + 'filename' => $entity->get('filename')->value, + 'location' => $entity->get('location')->value, + 'reason' => $entity->get('reason')->value, + 'created' => date('Y-m-d H:i:s', $entity->get('created')->value), + ]; + + $row['expires']['data'] = [ + '#markup' => $diff->invert ? "{$diff->format('%r%a days')}" : $diff->format('%r%a days'), + ]; + + $row['user']['data'] = [ + '#theme' => 'username', + '#account' => $entity->get('owner')->entity, + ]; + return $row + parent::buildRow($entity); + } + + /** + * {@inheritdoc} + */ + public function buildOperations(EntityInterface $entity) { + $ops = parent::buildOperations($entity); + $ops['#links']['view_users'] = [ + 'title' => $this->t('View Users'), + 'url' => Url::fromRoute('gdpr_view_export_log.view_users', ['id' => $entity->id()]), + 'weight' => 1, + ]; + return $ops; + } + +} diff --git a/modules/gdpr_view_export_log/src/Form/AuditDeleteForm.php b/modules/gdpr_view_export_log/src/Form/AuditDeleteForm.php new file mode 100644 index 0000000..c72cbd2 --- /dev/null +++ b/modules/gdpr_view_export_log/src/Form/AuditDeleteForm.php @@ -0,0 +1,26 @@ +t('Please only remove this export log if you have completely destroyed the export on the target computer and any copies of it. Are you sure you want to continue?'); + return $form; + } + +} diff --git a/modules/gdpr_view_export_log/src/Form/AuditForm.php b/modules/gdpr_view_export_log/src/Form/AuditForm.php new file mode 100644 index 0000000..dbd9d27 --- /dev/null +++ b/modules/gdpr_view_export_log/src/Form/AuditForm.php @@ -0,0 +1,73 @@ +t('Please provide some information about your export.'); + + $form['intro'] = [ + '#markup' => 'To continue, please enter details about how this export will be used.', + ]; + + $session = $this->getRequest()->getSession(); + + $form['continue_url'] = [ + '#type' => 'hidden', + '#default_value' => $session->get('gdpr_export_audit_continue'), + ]; + + $form['filename'] = [ + '#type' => 'hidden', + '#default_value' => $session->get('gdpr_export_audit_file'), + ]; + + $form['view'] = [ + '#type' => 'hidden', + '#default_value' => $session->get('gdpr_export_audit_view'), + ]; + + $session->remove('gdpr_export_audit_view'); + $session->remove('gdpr_export_audit_file'); + $session->remove('gdpr_export_audit_continue'); + + $form['actions']['submit']['#value'] = $this->t('Continue'); + return $form; + } + + /** + * {@inheritdoc} + */ + public function save(array $form, FormStateInterface $form_state) { + $entity = $this->entity; + $entity->set('owner', \Drupal::currentUser()->id()); + parent::save($form, $form_state); + + $url = urldecode($form_state->getValue('continue_url')); + if (strpos($url, '?') > -1) { + $url .= '&audited=1'; + } + else { + $url .= '?audited=1'; + } + + $this->getRequest()->getSession()->set('gdpr_audit_id', $entity->id()); + $form_state->setRedirectUrl(Url::fromUserInput($url)); + } + +} diff --git a/modules/gdpr_view_export_log/src/Form/RemoveUserFromExportForm.php b/modules/gdpr_view_export_log/src/Form/RemoveUserFromExportForm.php new file mode 100644 index 0000000..1dc4f07 --- /dev/null +++ b/modules/gdpr_view_export_log/src/Form/RemoveUserFromExportForm.php @@ -0,0 +1,75 @@ + $this->t('Only remove a user from this export if you have actually removed all their information from the export on the target machine and any copies'), + ]; + + $form['actions'] = [ + '#type' => 'actions', + 'submit' => [ + '#type' => 'submit', + '#value' => 'Remove', + ], + ]; + + $form['id'] = [ + '#type' => 'hidden', + '#default_value' => $id, + ]; + + $form['user_id'] = [ + '#type' => 'hidden', + '#default_value' => $user_id, + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $id_to_remove = $form_state->getValue('id'); + $audit_entry = ExportAudit::load($form_state->getValue('id')); + + foreach ($audit_entry->get('user_ids') as $index => $field) { + if ($field->value == $id_to_remove) { + $index_to_remove = $index; + break; + } + } + + if (isset($index_to_remove)) { + $audit_entry->get('user_ids')->removeItem($index_to_remove); + $audit_entry->save(); + } + + \Drupal::messenger()->addMessage($this->t('User removed')); + $form_state->setRedirect('gdpr_view_export_log.view_users', ['id' => $audit_entry->id()]); + } + +} diff --git a/modules/gdpr_view_export_log/src/Plugin/views/display_extender/GdprExportLogDisplayExtender.php b/modules/gdpr_view_export_log/src/Plugin/views/display_extender/GdprExportLogDisplayExtender.php new file mode 100644 index 0000000..22ab0f4 --- /dev/null +++ b/modules/gdpr_view_export_log/src/Plugin/views/display_extender/GdprExportLogDisplayExtender.php @@ -0,0 +1,120 @@ +displayHandler) == ExportAudit::exportDisplayHandler(); + } + + /** + * {@inheritdoc} + */ + public function defineOptionsAlter(&$options) { + $options['gdpr_log'] = [ + 'default' => FALSE, + 'contains' => [ + 'log_exports' => ['default' => 0], + ], + ]; + } + + /** + * {@inheritdoc} + */ + public function optionsSummary(&$categories, &$options) { + if ($this->isExportView()) { + $categories['gdpr_log'] = [ + 'title' => 'GDPR', + 'column' => 'second', + ]; + + $options['gdpr_log'] = [ + 'category' => 'gdpr_log', + 'title' => 'Log Exports', + 'value' => $this->loggingEnabled() ? 'Yes' : 'No', + ]; + } + } + + /** + * Whether logging is enabled for this view. + * + * @return bool + * Whether logging is enabled for this view. + */ + public function loggingEnabled() { + return array_key_exists('gdpr_log', $this->options) && $this->options['gdpr_log']['log_exports'] == TRUE; + } + + /** + * {@inheritdoc} + */ + public function buildOptionsForm(&$form, FormStateInterface $form_state) { + if ($form_state->get('section') == 'gdpr_log') { + $form['#title'] .= 'The GDPR Log settings'; + + $form['gdpr_log']['#type'] = 'container'; + $form['gdpr_log']['#tree'] = TRUE; + $form['gdpr_log']['log_exports'] = [ + '#title' => $this->t('Log Exports'), + '#description' => $this->t('Whether to log exports of this view'), + '#type' => 'checkbox', + '#default_value' => $this->loggingEnabled(), + ]; + } + } + + /** + * {@inheritdoc} + */ + public function submitOptionsForm(&$form, FormStateInterface $form_state) { + if ($form_state->get('section') == 'gdpr_log') { + $this->options['gdpr_log'] = $form_state->getValue('gdpr_log'); + } + } + + /** + * Whether logging is enabled for a particular view. + * + * @param \Drupal\views\ViewExecutable $view + * The view to check. + * + * @return bool + * Whether logging is enabled. + */ + public static function isLoggingEnabled(ViewExecutable $view) { + if (get_class($view->display_handler) == ExportAudit::exportDisplayHandler()) { + $extenders = $view->getDisplay()->getExtenders(); + if (array_key_exists('gdpr_view_export_logging', $extenders)) { + return $extenders['gdpr_view_export_logging']->options['gdpr_log']['log_exports'] == 1; + } + } + + return FALSE; + } + +}