diff --git a/README.md b/README.md index 7af4fdb..eb7715f 100644 --- a/README.md +++ b/README.md @@ -25,19 +25,23 @@ be GDPR compliant YO! ## Configuration -This module can be configured by editing the `gdpr_dumper.settings.yml` [file](https://github.com/robiningelbrecht/gdpr-dumper/blob/master/config/install/gdpr_dumper.settings.yml). +This module can be configured by navigating to `admin/config/development/gdpr-dumper`. +On this page you can configure the sanitization and anonymization +of every column of every table in your database. +## Events + +The module dispatches one event: +* `GdprDumperEvents::GDPR_REPLACEMENTS` + +This allows developers to alter the replacements through event subscribers on run-time. [machbarmacher/gdpr-dump](https://github.com/machbarmacher/gdpr-dump) contains more info about -the **gdpr-expressions** and **gdpr-replacement** options. +the **gdpr-replacement** options. The provided yml file expects the same structure as explained in the readme above. -## Events +## TODO -The module dispatches two events: -* `GdprDumperEvents::GDPR_EXPRESSIONS` -* `GdprDumperEvents::GDPR_REPLACEMENTS` - -This allows developers to alter the expressions and replacements through event subscribers on run-time +* Provide a way to allow to export the structure of a table without the data. Happy GDPR'ing! diff --git a/config/install/gdpr_dumper.settings.yml b/config/install/gdpr_dumper.settings.yml index 5b0c8b5..db863c7 100644 --- a/config/install/gdpr_dumper.settings.yml +++ b/config/install/gdpr_dumper.settings.yml @@ -1,6 +1,4 @@ -gdpr_expressions: - users_field_data: - init: 'uid' +empty_tables: {} gdpr_replacements: users_field_data: name: @@ -8,15 +6,4 @@ gdpr_replacements: mail: formatter: 'email' pass: - formatter: 'clear' -drivers: - mysql: - dump_command: 'mysqldump' - oracle: - dump_command: 'mysqldump' - pqsql: - dump_command: 'pg_dump' - sqlite: - dump_command: 'dump' - sqlsrv: - dump_command: 'mysqldump' \ No newline at end of file + formatter: 'clear' \ No newline at end of file diff --git a/gdpr_dumper.libraries.yml b/gdpr_dumper.libraries.yml new file mode 100644 index 0000000..c4a4267 --- /dev/null +++ b/gdpr_dumper.libraries.yml @@ -0,0 +1,8 @@ +settings-form: + version: 1.x + js: + js/gdpr-dumper.js: {} + dependencies: + - core/jquery + - core/drupal.form + diff --git a/gdpr_dumper.links.menu.yml b/gdpr_dumper.links.menu.yml new file mode 100644 index 0000000..d1ae0d5 --- /dev/null +++ b/gdpr_dumper.links.menu.yml @@ -0,0 +1,5 @@ +gdpr_dumper.settings: + title: 'GDPR dumper' + description: 'Configure GDPR dumper drush command' + parent: system.admin_config_development + route_name: gdpr_dumper.settings \ No newline at end of file diff --git a/gdpr_dumper.permissions.yml b/gdpr_dumper.permissions.yml new file mode 100644 index 0000000..c2e61ad --- /dev/null +++ b/gdpr_dumper.permissions.yml @@ -0,0 +1,3 @@ +administer gdpr dumper: + title: 'Administer gdpr dumper' + description: 'Allow to configure the gdpr dumper drush command' diff --git a/gdpr_dumper.routing.yml b/gdpr_dumper.routing.yml new file mode 100644 index 0000000..7d90552 --- /dev/null +++ b/gdpr_dumper.routing.yml @@ -0,0 +1,7 @@ +gdpr_dumper.settings: + path: '/admin/config/development/gdpr-dumper' + defaults: + _form: '\Drupal\gdpr_dumper\Form\GdprDumperSettingsForm' + _title: 'GDPR dumper' + requirements: + _permission: 'administer gdpr dumper' diff --git a/gdpr_dumper.services.yml b/gdpr_dumper.services.yml new file mode 100644 index 0000000..80187b9 --- /dev/null +++ b/gdpr_dumper.services.yml @@ -0,0 +1,4 @@ +services: + gdpr_dumper.database_manager: + class: Drupal\gdpr_dumper\Manager\DatabaseManager + arguments: ['@database'] \ No newline at end of file diff --git a/js/gdpr-dumper.js b/js/gdpr-dumper.js new file mode 100644 index 0000000..9b78ed8 --- /dev/null +++ b/js/gdpr-dumper.js @@ -0,0 +1,13 @@ +(function($, Drupal) { + + Drupal.behaviors.gdprDumperSummary = { + attach: function (context, settings) { + // Display the action in the vertical tab summary. + $(context).find('details[data-table-summary]').drupalSetSummary(function(context) { + return Drupal.checkPlain($(context).data('table-summary')); + }); + + } + } + +})(jQuery, Drupal); \ No newline at end of file diff --git a/src/Event/GdprDumperEvents.php b/src/Event/GdprDumperEvents.php index b8a05c4..fafc9f9 100644 --- a/src/Event/GdprDumperEvents.php +++ b/src/Event/GdprDumperEvents.php @@ -7,15 +7,6 @@ */ final class GdprDumperEvents { - /** - * Name of the event fired building the GDPR expressions. - * - * @Event - * - * @see \Drupal\gdpr_dumper\Event\GdprExpressionsEvent - */ - const GDPR_EXPRESSIONS = 'gdpr_dumper.expressions'; - /** * Name of the event fired building the GDPR replacements. * diff --git a/src/Event/GdprExpressionsEvent.php b/src/Event/GdprExpressionsEvent.php deleted file mode 100644 index 6c0b963..0000000 --- a/src/Event/GdprExpressionsEvent.php +++ /dev/null @@ -1,43 +0,0 @@ -expressions = $expressions; - } - - /** - * @return array - */ - public function getExpressions() { - return $this->expressions; - } - - /** - * @param array $expressions - * @return $this - */ - public function setExpressions(array $expressions) { - $this->expressions = $expressions; - return $this; - } - -} diff --git a/src/Form/GdprDumperSettingsForm.php b/src/Form/GdprDumperSettingsForm.php new file mode 100644 index 0000000..ef7eea7 --- /dev/null +++ b/src/Form/GdprDumperSettingsForm.php @@ -0,0 +1,229 @@ +connection = $connection; + $this->databaseManager = $database_manager; + $this->settings = $this->config('gdpr_dumper.settings'); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('config.factory'), + $container->get('database'), + $container->get('gdpr_dumper.database_manager') + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'gdpr_dumper_settings_form'; + } + + /** + * {@inheritdoc} + */ + protected function getEditableConfigNames() { + return ['gdpr_dumper.settings']; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state) { + $replacements = $this->settings->get('gdpr_replacements'); + $empty_tables = $this->settings->get('empty_tables'); + // Add the empty table options to the replacement array, if they don't exist already. + // We need this because if only the "Empty table" option is checked, + // the table won't be available in the replacements array. + foreach (array_filter($empty_tables) as $empty_table => $value) { + if (!isset($replacements[$empty_table])) { + $replacements[$empty_table] = []; + } + } + + + $database_tables = $this->databaseManager->getTableColumns(); + $db_schema = $this->connection->schema(); + $schema_handles_db_comments = \is_callable([$db_schema, 'getComment']); + + $form['intro'] = [ + '#type' => 'item', + '#title' => $this->t('Manage tables and columns that contain sensitive data'), + ]; + + $tables_to_add = array_diff(array_keys($database_tables), array_keys($replacements)); + + $form['table'] = [ + '#type' => 'select', + '#title' => $this->t('Select table'), + '#title_display' => 'invisible', + '#options' => array_combine($tables_to_add, $tables_to_add), + '#empty_value' => '', + '#empty_option' => $this->t('- Select -'), + ]; + + $form['add_table'] = [ + 'actions' => [ + '#type' => 'actions', + 'submit' => [ + '#type' => 'submit', + '#value' => $this->t('Add table'), + '#submit' => [ + [$this, 'submitAddTable'] + ], + ], + ], + ]; + + $form['advanced'] = [ + '#type' => 'vertical_tabs', + ]; + + $form['replacements'] = [ + '#tree' => TRUE, + '#attached' => [ + 'library' => ['gdpr_dumper/settings-form'] + ], + ]; + + foreach ($replacements as $table => $columns) { + if (isset($database_tables[$table])) { + $table_summary = $schema_handles_db_comments ? $db_schema->getComment($table) : '-'; + $form['replacements'][$table] = [ + '#type' => 'details', + '#title' => $table, + '#group' => 'advanced', + '#attributes' => [ + 'data-table-summary' => $table_summary, + ], + ]; + + $form['replacements'][$table]['columns'] = [ + '#type' => 'table', + '#caption' => $table_summary, + '#header' => [ + ['data' => $this->t('Field')], + ['data' => $this->t('Type')], + ['data' => $this->t('Description')], + ['data' => $this->t('Apply anonymization')], + ], + ]; + + foreach ($database_tables[$table] as $column_name => $column_properties) { + $form['replacements'][$table]['columns'][$column_name]['field'] = [ + '#markup' => '' . $column_properties['COLUMN_NAME'] . '', + ]; + $form['replacements'][$table]['columns'][$column_name]['data_type'] = [ + '#markup' => '' . $column_properties['DATA_TYPE'] . '', + ]; + $form['replacements'][$table]['columns'][$column_name]['comment'] = [ + '#markup' => '' . (empty($column_properties['COLUMN_COMMENT']) ? '-' : $column_properties['COLUMN_COMMENT']) . '', + ]; + $form['replacements'][$table]['columns'][$column_name]['anonymization'] = [ + '#type' => 'select', + '#title' => $this->t('Apply anonymization'), + '#title_display' => 'invisible', + '#options' => GdprDumperEnums::fakerFormatters(), + '#empty_value' => '', + '#empty_option' => $this->t('- No -'), + '#required' => FALSE, + '#default_value' => isset($replacements[$table][$column_name]['formatter']) ? $replacements[$table][$column_name]['formatter'] : FALSE, + ]; + } + + $form['replacements'][$table]['empty'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Empty this table'), + '#button_type' => 'secondary', + '#default_value' => isset($empty_tables[$table]) ? $empty_tables[$table] : FALSE, + ]; + } + } + + return parent::buildForm($form, $form_state); + } + + /** + * Submit callback to add a table to the list. + * + * @param array $form + * @param \Drupal\Core\Form\FormStateInterface $form_state + */ + public function submitAddTable(array &$form, FormStateInterface $form_state) { + if ($table = $form_state->getValue('table')) { + $replacements = $this->settings->get('gdpr_replacements'); + + if (!isset($replacements[$table])) { + $replacements[$table] = []; + } + + // Order tables alphabetically before saving. + ksort($replacements); + + // Update config. + $this->settings->set('gdpr_replacements', $replacements)->save(); + $this->messenger() + ->addStatus($this->t('The table has been added. You can configure it by selecting the corresponding tab.')); + } + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $settings = [ + 'gdpr_replacements' => [], + 'empty_tables' => [], + ]; + + $replacements = $form_state->getValue('replacements'); + // Format the replacement to a suitable config array. + foreach ($replacements as $table_name => $properties) { + foreach ($properties['columns'] as $column_name => $column) { + if (!empty($column['anonymization'])) { + $settings['gdpr_replacements'][$table_name][$column_name]['formatter'] = $column['anonymization']; + } + }; + $settings['empty_tables'][$table_name] = $properties['empty']; + } + + // Save settings. + $this->settings->set('gdpr_replacements', $settings['gdpr_replacements']) + ->set('empty_tables', $settings['empty_tables'])->save(); + + parent::submitForm($form, $form_state); + } + +} \ No newline at end of file diff --git a/src/GdprDumperEnums.php b/src/GdprDumperEnums.php new file mode 100644 index 0000000..98068f2 --- /dev/null +++ b/src/GdprDumperEnums.php @@ -0,0 +1,57 @@ + 'Generate a name', + 'phoneNumber' => t('Generate a phone number'), + 'username' => t('Generate a random user name'), + 'password' => t('Generate a random password'), + 'email' => t('Generate a random email address'), + 'date' => t('Generate a date'), + 'longText' => t('Generate a sentence'), + 'number' => t('Generate a number'), + 'randomText' => t('Generate a sentence'), + 'text' => t('Generate a paragraph'), + 'uri' => t('Generate a URI'), + 'clear' => t('Generate an empty string'), + ]; + } + + /** + * @param $driver + * @return array + */ + public static function driverOptions($driver) { + $map = [ + 'mysql' => [ + 'dump_command' => 'mysqldump', + ], + 'oracle' => [ + 'dump_command' => 'mysqldump', + ], + 'pqsql' => [ + 'dump_command' => 'pg_dump', + ], + 'sqlite' => [ + 'dump_command' => 'dump', + ], + 'sqlsrv' => [ + 'dump_command' => 'mysqldump', + ], + ]; + + return isset($map[$driver]) ? $map[$driver] : []; + } + +} \ No newline at end of file diff --git a/src/Manager/DatabaseManager.php b/src/Manager/DatabaseManager.php new file mode 100644 index 0000000..e891612 --- /dev/null +++ b/src/Manager/DatabaseManager.php @@ -0,0 +1,53 @@ +database = $database; + } + + /** + * @return array + */ + public function getTableColumns() { + $tables = $this->database->schema()->findTables('%'); + $columns = []; + foreach ($tables as $table) { + $result = $this->getColumns($table); + if (NULL === $result) { + continue; + } + $columns[$table] = $result->fetchAllAssoc('COLUMN_NAME', \PDO::FETCH_ASSOC); + } + + return $columns; + } + + /** + * @param $table + * @return \Drupal\Core\Database\StatementInterface|null + */ + protected function getColumns($table) { + // @todo: How cross-driver is this? + $query = $this->database->select('information_schema.columns', 'columns'); + $query->fields('columns', ['COLUMN_NAME', 'DATA_TYPE', 'COLUMN_COMMENT']); + $query->condition('TABLE_SCHEMA', $this->database->getConnectionOptions()['database']); + $query->condition('TABLE_NAME', $table); + return $query->execute(); + } +} \ No newline at end of file diff --git a/src/Sql/GdprSqlBase.php b/src/Sql/GdprSqlBase.php index 72c3341..ea1d8b1 100644 --- a/src/Sql/GdprSqlBase.php +++ b/src/Sql/GdprSqlBase.php @@ -5,8 +5,8 @@ use Drupal\Component\Serialization\Json; use Drupal\Core\Database\Database; use Drupal\gdpr_dumper\Event\GdprDumperEvents; -use Drupal\gdpr_dumper\Event\GdprExpressionsEvent; use Drupal\gdpr_dumper\Event\GdprReplacementsEvent; +use Drupal\gdpr_dumper\GdprDumperEnums; use Drush\Drush; use Drush\Sql\SqlBase; use Symfony\Component\EventDispatcher\EventDispatcherInterface; @@ -61,17 +61,6 @@ public static function getInstance($dbSpec, $options, EventDispatcherInterface $ // Fetch module settings. $config = \Drupal::config('gdpr_dumper.settings'); - if (empty($options['extra-dump']) || strpos($options['extra-dump'], '--gdpr-expressions') === FALSE) { - - // Dispatch event so the expressions can be altered. - $event = new GdprExpressionsEvent($config->get('gdpr_expressions')); - $event_dispatcher->dispatch(GdprDumperEvents::GDPR_EXPRESSIONS, $event); - // Add the configured GDPR expressions to the command. - if($expressions = Json::encode($event->getExpressions())){ - $options['extra-dump'] .= " --gdpr-expressions='$expressions'"; - } - } - if (empty($options['extra-dump']) || strpos($options['extra-dump'], '--gdpr-replacements') === FALSE) { // Dispatch event so the replacements can be altered. $event = new GdprReplacementsEvent($config->get('gdpr_replacements')); @@ -83,10 +72,9 @@ public static function getInstance($dbSpec, $options, EventDispatcherInterface $ } $instance = new $className($dbSpec, $options); - $driver_options = isset($config->get('drivers')[$driver]) ? $config->get('drivers')[$driver] : []; // Inject config $instance->setConfig(Drush::config()); - $instance->setDriverOptions($driver_options); + $instance->setDriverOptions(GdprDumperEnums::driverOptions($driver)); return $instance; }