Skip to content

Commit

Permalink
Convert sql:sanitize command. Convert the UserTable sanitizer to a Li…
Browse files Browse the repository at this point in the history
…stener
  • Loading branch information
weitzman committed Oct 24, 2024
1 parent 74b697c commit 5b55f98
Show file tree
Hide file tree
Showing 6 changed files with 198 additions and 96 deletions.
88 changes: 88 additions & 0 deletions src/Commands/sql/sanitize/SanitizeCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

declare(strict_types=1);

namespace Drush\Commands\sql\sanitize;

use Consolidation\AnnotatedCommand\Events\CustomEventAwareInterface;
use Consolidation\AnnotatedCommand\Events\CustomEventAwareTrait;
use Drush\Attributes as CLI;
use Drush\Boot\DrupalBootLevels;
use Drush\Commands\AutowireTrait;
use Drush\Commands\core\DocsCommands;
use Drush\Event\SanitizeConfirmsEvent;
use Drush\Exceptions\UserAbortException;
use Drush\Style\DrushStyle;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(
name: self::NAME,
description: 'Sanitize the database by removing or obfuscating user data.',
aliases: ['sqlsan','sql-sanitize']
)]
// @todo Deal with topics on classes.
#[CLI\Topics(topics: [DocsCommands::HOOKS])]
#[CLI\Bootstrap(level: DrupalBootLevels::FULL)]
final class SanitizeCommand extends Command implements CustomEventAwareInterface
{
use AutowireTrait;
use CustomEventAwareTrait;

const NAME = 'sql:sanitize';

public function __construct(protected EventDispatcherInterface $eventDispatcher)
{
parent::__construct();
}


protected function configure()
{
$this
->setDescription('Sanitize the database by removing or obfuscating user data.')
->addUsage('drush sql:sanitize --sanitize-password=no')
->addUsage('drush sql:sanitize --allowlist-fields=field_biography,field_phone_number');
}

/**
* Commandfiles may add custom operations by implementing a Listener that subscribes to two events:
*
* - `\Drush\Events\SanitizeConfirmsEvent`. Display summary to user before confirmation.
* - `\Symfony\Component\Console\Event\ConsoleTerminateEvent`. Run queries or call APIs to perform sanitizing
*
* Several working Listeners may be found at https://github.com/drush-ops/drush/tree/13.x/src/Drush/Listeners/sanitize
*/

protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new DrushStyle($input, $output);

/**
* In order to present only one prompt, collect all confirmations up front.
*/
$event = new SanitizeConfirmsEvent($input);
$this->eventDispatcher->dispatch($event, SanitizeConfirmsEvent::class);
$messages = $event->getMessages();

// Also collect from legacy commandfiles.
$handlers = $this->getCustomEventHandlers(SanitizeCommands::CONFIRMS);
foreach ($handlers as $handler) {
$handler($messages, $input);
}
// @phpstan-ignore if.alwaysFalse
if ($messages) {
$output->writeln(dt('The following operations will be performed:'));
$io->listing($messages);
}
if (!$io->confirm(dt('Do you want to sanitize the current database?'))) {
throw new UserAbortException();
}
// All sanitize operations happen during the built-in console.terminate event.

return self::SUCCESS;
}
}
54 changes: 3 additions & 51 deletions src/Commands/sql/sanitize/SanitizeCommands.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,59 +4,11 @@

namespace Drush\Commands\sql\sanitize;

use Consolidation\AnnotatedCommand\Events\CustomEventAwareInterface;
use Consolidation\AnnotatedCommand\Events\CustomEventAwareTrait;
use Drush\Attributes as CLI;
use Drush\Boot\DrupalBootLevels;
use Drush\Commands\core\DocsCommands;
use Drush\Commands\DrushCommands;
use Drush\Exceptions\UserAbortException;
use JetBrains\PhpStorm\Deprecated;

#[CLI\Bootstrap(level: DrupalBootLevels::FULL)]
final class SanitizeCommands extends DrushCommands implements CustomEventAwareInterface
#[Deprecated('Moved to Drush\Commands\sql\sanitize\SanitizeCommand.')]
final class SanitizeCommands
{
use CustomEventAwareTrait;

const SANITIZE = 'sql:sanitize';
const CONFIRMS = 'sql-sanitize-confirms';

/**
* Sanitize the database by removing or obfuscating user data.
*
* Commandfiles may add custom operations by implementing:
*
* - `#[CLI\Hook(type: HookManager::ON_EVENT, target: SanitizeCommands::CONFIRMS)]`. Display summary to user before confirmation.
* - `#[CLI\Hook(type: HookManager::POST_COMMAND_HOOK, target: SanitizeCommands::SANITIZE)]`. Run queries or call APIs to perform sanitizing
*
* Several working commandfiles may be found at https://github.com/drush-ops/drush/tree/13.x/src/Commands/sql/sanitize
*/
#[CLI\Command(name: self::SANITIZE, aliases: ['sqlsan','sql-sanitize'])]
#[CLI\Usage(name: 'drush sql:sanitize --sanitize-password=no', description: 'Sanitize database without modifying any passwords.')]
#[CLI\Usage(name: 'drush sql:sanitize --allowlist-fields=field_biography,field_phone_number', description: 'Sanitizes database but exempts two user fields from modification.')]
#[CLI\Topics(topics: [DocsCommands::HOOKS])]
public function sanitize(): void
{
/**
* In order to present only one prompt, collect all confirmations from
* commandfiles up front. sql:sanitize plugins are commandfiles that implement
* \Drush\Commands\sql\SanitizePluginInterface
*/
$messages = [];
$input = $this->input();
$handlers = $this->getCustomEventHandlers(self::CONFIRMS);
foreach ($handlers as $handler) {
$handler($messages, $input);
}
// @phpstan-ignore if.alwaysFalse
if ($messages) {
$this->output()->writeln(dt('The following operations will be performed:'));
$this->io()->listing($messages);
}
if (!$this->io()->confirm(dt('Do you want to sanitize the current database?'))) {
throw new UserAbortException();
}

// All sanitize operations defined in post-command hooks, including Drush
// core sanitize routines. See \Drush\Commands\sql\sanitize\SanitizePluginInterface.
}
}
2 changes: 2 additions & 0 deletions src/Commands/sql/sanitize/SanitizePluginInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
namespace Drush\Commands\sql\sanitize;

use Consolidation\AnnotatedCommand\CommandData;
use JetBrains\PhpStorm\Deprecated;
use Symfony\Component\Console\Input\InputInterface;

/**
* Implement this interface when building a Drush sql-sanitize plugin.
*/
#[Deprecated(reason: 'Implement an event listener instead.')]
interface SanitizePluginInterface
{
/**
Expand Down
48 changes: 48 additions & 0 deletions src/Event/SanitizeConfirmsEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

namespace Drush\Event;

use Symfony\Component\Console\Input\InputInterface;
use Symfony\Contracts\EventDispatcher\Event;

/*
* A custom event, for prompting the user about candidate sanitize operations.
*
* Listeners should add their confirm messages via addMessage().
*/

final class SanitizeConfirmsEvent extends Event
{
public function __construct(
protected InputInterface $input,
protected array $messages = [],
) {
}

public function setInput(InputInterface $input): void
{
$this->input = $input;
}

public function getInput(): InputInterface
{
return $this->input;
}

public function addMessage(string $message): self
{
$this->messages[] = $message;
return $this;
}

public function getMessages(): array
{
return $this->messages;
}

public function setMessages(array $messages): self
{
$this->messages = $messages;
return $this;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,44 +2,76 @@

declare(strict_types=1);

namespace Drush\Commands\sql\sanitize;
namespace Drush\Listeners\sanitize;

use Drupal\Core\Database\Connection;
use Drupal\Core\Database\Query\SelectInterface;
use Consolidation\AnnotatedCommand\CommandData;
use Consolidation\AnnotatedCommand\Hooks\HookManager;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Password\PasswordInterface;
use Drush\Attributes as CLI;
use Drush\Commands\AutowireTrait;
use Drush\Commands\DrushCommands;
use Drush\Commands\sql\sanitize\SanitizeCommand;
use Drush\Event\ConsoleDefinitionsEvent;
use Drush\Event\SanitizeConfirmsEvent;
use Drush\Sql\SqlBase;
use Drush\Utils\StringUtils;
use Symfony\Component\Console\Input\InputInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Event\ConsoleTerminateEvent;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

/**
* A sql:sanitize plugin.
* Sanitize emails and passwords. This also an example of how to write a
* database sanitizer for sql:sync.
*/
final class SanitizeUserTableCommands extends DrushCommands implements SanitizePluginInterface
#[AsEventListener(method: 'onDefinition')]
#[AsEventListener(method: 'onSanitizeConfirm')]
#[AsEventListener(method: 'onConsoleTerminate')]
final class SanitizeUserTableListener
{
use AutowireTrait;

public function __construct(
protected Connection $database,
protected PasswordInterface $passwordHasher,
protected EntityTypeManagerInterface $entityTypeManager
protected EntityTypeManagerInterface $entityTypeManager,
protected LoggerInterface $logger,
) {
parent::__construct();
}

/**
* Sanitize emails and passwords. This also an example of how to write a
* database sanitizer for sql:sync.
*/
#[CLI\Hook(type: HookManager::POST_COMMAND_HOOK, target: SanitizeCommands::SANITIZE)]
public function sanitize($result, CommandData $commandData): void
public function onDefinition(ConsoleDefinitionsEvent $event): void
{
foreach ($event->getApplication()->all() as $id => $command) {
if ($command->getName() === SanitizeCommand::NAME) {
$command->addOption(
'sanitize-email',
null,
InputOption::VALUE_REQUIRED,
'The pattern for test email addresses in the sanitization operation, or <info>no</info> to keep email addresses unchanged. May contain replacement patterns <info>%uid</info>, <info>%mail</info> or <info>%name</info>.',
'user+%[email protected]'
)
->addOption('sanitize-password', null, InputOption::VALUE_REQUIRED, 'By default, passwords are randomized. Specify <info>no</info> to disable that. Specify any other value to set all passwords to that value.')
->addOption('ignored-roles', null, InputOption::VALUE_REQUIRED, 'A comma delimited list of roles. Users with at least one of the roles will be exempt from sanitization.');
}
}
}

public function onSanitizeConfirm(SanitizeConfirmsEvent $event): void
{
$options = $event->getInput()->getOptions();
if ($this->isEnabled($options['sanitize-password'])) {
$event->addMessage(dt('Sanitize user passwords.'));
}
if ($this->isEnabled($options['sanitize-email'])) {
$event->addMessage(dt('Sanitize user emails.'));
}
if (in_array('ignored-roles', $options)) {
$event->addMessage(dt('Preserve user emails and passwords for the specified roles.'));
}
}

public function onConsoleTerminate(ConsoleTerminateEvent $event): void
{
$options = $commandData->options();
$options = $event->getInput()->getOptions();
$query = $this->database->update('users_field_data')->condition('uid', 0, '>');
$messages = [];

Expand All @@ -60,7 +92,7 @@ public function sanitize($result, CommandData $commandData): void
if ($this->isEnabled($options['sanitize-email'])) {
if (str_contains($options['sanitize-email'], '%')) {
// We need a different sanitization query for MSSQL, Postgres and Mysql.
$sql = SqlBase::create($commandData->input()->getOptions());
$sql = SqlBase::create($event->getInput()->getOptions());
$db_driver = $sql->scheme();
if ($db_driver === 'pgsql') {
$email_map = ['%uid' => "' || uid || '", '%mail' => "' || replace(mail, '@', '_') || '", '%name' => "' || replace(name, ' ', '_') || '"];
Expand Down Expand Up @@ -100,34 +132,11 @@ public function sanitize($result, CommandData $commandData): void
$query->execute();
$this->entityTypeManager->getStorage('user')->resetCache();
foreach ($messages as $message) {
$this->logger()->success($message);
$this->logger->success($message);
}
}
}

#[CLI\Hook(type: HookManager::OPTION_HOOK, target: SanitizeCommands::SANITIZE)]
#[CLI\Option(name: 'sanitize-email', description: 'The pattern for test email addresses in the sanitization operation, or <info>no</info> to keep email addresses unchanged. May contain replacement patterns <info>%uid</info>, <info>%mail</info> or <info>%name</info>.')]
#[CLI\Option(name: 'sanitize-password', description: 'By default, passwords are randomized. Specify <info>no</info> to disable that. Specify any other value to set all passwords to that value.')]
#[CLI\Option(name: 'ignored-roles', description: 'A comma delimited list of roles. Users with at least one of the roles will be exempt from sanitization.')]
public function options($options = ['sanitize-email' => 'user+%[email protected]', 'sanitize-password' => null, 'ignored-roles' => null]): void
{
}

#[CLI\Hook(type: HookManager::ON_EVENT, target: SanitizeCommands::CONFIRMS)]
public function messages(&$messages, InputInterface $input): void
{
$options = $input->getOptions();
if ($this->isEnabled($options['sanitize-password'])) {
$messages[] = dt('Sanitize user passwords.');
}
if ($this->isEnabled($options['sanitize-email'])) {
$messages[] = dt('Sanitize user emails.');
}
if (in_array('ignored-roles', $options)) {
$messages[] = dt('Preserve user emails and passwords for the specified roles.');
}
}

/**
* Test an option value to see if it is disabled.
*/
Expand Down
9 changes: 6 additions & 3 deletions src/Runtime/ServiceManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\ConsoleEvents;
use Symfony\Component\Console\Event\ConsoleCommandEvent;
use Symfony\Component\Console\Event\ConsoleTerminateEvent;
use Symfony\Component\Console\Input\InputAwareInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

Expand Down Expand Up @@ -438,9 +439,11 @@ public function addListeners(iterable $classes, ContainerInterface $drushContain
} else {
throw new \Exception('Event listener method must have a single parameter with a type hint.');
}
if ($eventName == ConsoleCommandEvent::class) {
$eventName = ConsoleEvents::COMMAND;
}
$eventName = match ($eventName) {
ConsoleCommandEvent::class => ConsoleEvents::COMMAND,
ConsoleTerminateEvent::class => ConsoleEvents::TERMINATE,
default => $eventName,
};
Drush::getContainer()->get('eventDispatcher')->addListener($eventName, $instance->$method(...), $priority);
}
}
Expand Down

0 comments on commit 5b55f98

Please sign in to comment.