Skip to content

Commit 494faef

Browse files
committed
Convert sql:sanitize command. Convert the UserTable sanitizer to a Listener
1 parent 74b697c commit 494faef

File tree

6 files changed

+294
-54
lines changed

6 files changed

+294
-54
lines changed
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Drush\Commands\sql\sanitize;
6+
7+
use Consolidation\AnnotatedCommand\Events\CustomEventAwareInterface;
8+
use Consolidation\AnnotatedCommand\Events\CustomEventAwareTrait;
9+
use Drush\Attributes as CLI;
10+
use Drush\Boot\DrupalBootLevels;
11+
use Drush\Commands\AutowireTrait;
12+
use Drush\Commands\core\DocsCommands;
13+
use Drush\Event\SanitizeConfirmsEvent;
14+
use Drush\Exceptions\UserAbortException;
15+
use Drush\Style\DrushStyle;
16+
use Psr\EventDispatcher\EventDispatcherInterface;
17+
use Symfony\Component\Console\Attribute\AsCommand;
18+
use Symfony\Component\Console\Command\Command;
19+
use Symfony\Component\Console\Input\InputInterface;
20+
use Symfony\Component\Console\Output\OutputInterface;
21+
22+
#[AsCommand(
23+
name: self::NAME,
24+
description: 'Sanitize the database by removing or obfuscating user data.',
25+
aliases: ['sqlsan','sql-sanitize']
26+
)]
27+
// @todo Deal with topics on classes.
28+
#[CLI\Topics(topics: [DocsCommands::HOOKS])]
29+
#[CLI\Bootstrap(level: DrupalBootLevels::FULL)]
30+
final class SanitizeCommand extends Command implements CustomEventAwareInterface
31+
{
32+
use AutowireTrait;
33+
use CustomEventAwareTrait;
34+
35+
const NAME = 'sql:sanitize';
36+
37+
public function __construct(protected EventDispatcherInterface $eventDispatcher)
38+
{
39+
parent::__construct();
40+
}
41+
42+
43+
protected function configure()
44+
{
45+
$this
46+
->setDescription('Sanitize the database by removing or obfuscating user data.')
47+
->addUsage('drush sql:sanitize --sanitize-password=no')
48+
->addUsage('drush sql:sanitize --allowlist-fields=field_biography,field_phone_number');
49+
}
50+
51+
/**
52+
* Commandfiles may add custom operations by implementing a Listener that subscribes to two events:
53+
*
54+
* - `\Drush\Events\SanitizeConfirmsEvent`. Display summary to user before confirmation.
55+
* - `\Symfony\Component\Console\Event\ConsoleTerminateEvent`. Run queries or call APIs to perform sanitizing
56+
*
57+
* Several working Listeners may be found at https://github.com/drush-ops/drush/tree/13.x/src/Drush/Listeners/sanitize
58+
*/
59+
60+
protected function execute(InputInterface $input, OutputInterface $output): int
61+
{
62+
$io = new DrushStyle($input, $output);
63+
64+
/**
65+
* In order to present only one prompt, collect all confirmations up front.
66+
*/
67+
$event = new SanitizeConfirmsEvent($input);
68+
$this->eventDispatcher->dispatch($event, SanitizeConfirmsEvent::class);
69+
$messages = $event->getMessages();
70+
71+
// Also collect from legacy commandfiles.
72+
$handlers = $this->getCustomEventHandlers(SanitizeCommands::CONFIRMS);
73+
foreach ($handlers as $handler) {
74+
$handler($messages, $input);
75+
}
76+
// @phpstan-ignore if.alwaysFalse
77+
if ($messages) {
78+
$output->writeln(dt('The following operations will be performed:'));
79+
$io->listing($messages);
80+
}
81+
if (!$io->confirm(dt('Do you want to sanitize the current database?'))) {
82+
throw new UserAbortException();
83+
}
84+
// All sanitize operations happen during the built-in console.terminate event.
85+
86+
return self::SUCCESS;
87+
}
88+
}

src/Commands/sql/sanitize/SanitizeCommands.php

Lines changed: 3 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4,59 +4,11 @@
44

55
namespace Drush\Commands\sql\sanitize;
66

7-
use Consolidation\AnnotatedCommand\Events\CustomEventAwareInterface;
8-
use Consolidation\AnnotatedCommand\Events\CustomEventAwareTrait;
9-
use Drush\Attributes as CLI;
10-
use Drush\Boot\DrupalBootLevels;
11-
use Drush\Commands\core\DocsCommands;
12-
use Drush\Commands\DrushCommands;
13-
use Drush\Exceptions\UserAbortException;
7+
use JetBrains\PhpStorm\Deprecated;
148

15-
#[CLI\Bootstrap(level: DrupalBootLevels::FULL)]
16-
final class SanitizeCommands extends DrushCommands implements CustomEventAwareInterface
9+
#[Deprecated('Moved to Drush\Commands\sql\sanitize\SanitizeCommand.')]
10+
final class SanitizeCommands
1711
{
18-
use CustomEventAwareTrait;
19-
2012
const SANITIZE = 'sql:sanitize';
2113
const CONFIRMS = 'sql-sanitize-confirms';
22-
23-
/**
24-
* Sanitize the database by removing or obfuscating user data.
25-
*
26-
* Commandfiles may add custom operations by implementing:
27-
*
28-
* - `#[CLI\Hook(type: HookManager::ON_EVENT, target: SanitizeCommands::CONFIRMS)]`. Display summary to user before confirmation.
29-
* - `#[CLI\Hook(type: HookManager::POST_COMMAND_HOOK, target: SanitizeCommands::SANITIZE)]`. Run queries or call APIs to perform sanitizing
30-
*
31-
* Several working commandfiles may be found at https://github.com/drush-ops/drush/tree/13.x/src/Commands/sql/sanitize
32-
*/
33-
#[CLI\Command(name: self::SANITIZE, aliases: ['sqlsan','sql-sanitize'])]
34-
#[CLI\Usage(name: 'drush sql:sanitize --sanitize-password=no', description: 'Sanitize database without modifying any passwords.')]
35-
#[CLI\Usage(name: 'drush sql:sanitize --allowlist-fields=field_biography,field_phone_number', description: 'Sanitizes database but exempts two user fields from modification.')]
36-
#[CLI\Topics(topics: [DocsCommands::HOOKS])]
37-
public function sanitize(): void
38-
{
39-
/**
40-
* In order to present only one prompt, collect all confirmations from
41-
* commandfiles up front. sql:sanitize plugins are commandfiles that implement
42-
* \Drush\Commands\sql\SanitizePluginInterface
43-
*/
44-
$messages = [];
45-
$input = $this->input();
46-
$handlers = $this->getCustomEventHandlers(self::CONFIRMS);
47-
foreach ($handlers as $handler) {
48-
$handler($messages, $input);
49-
}
50-
// @phpstan-ignore if.alwaysFalse
51-
if ($messages) {
52-
$this->output()->writeln(dt('The following operations will be performed:'));
53-
$this->io()->listing($messages);
54-
}
55-
if (!$this->io()->confirm(dt('Do you want to sanitize the current database?'))) {
56-
throw new UserAbortException();
57-
}
58-
59-
// All sanitize operations defined in post-command hooks, including Drush
60-
// core sanitize routines. See \Drush\Commands\sql\sanitize\SanitizePluginInterface.
61-
}
6214
}

src/Commands/sql/sanitize/SanitizePluginInterface.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
namespace Drush\Commands\sql\sanitize;
66

77
use Consolidation\AnnotatedCommand\CommandData;
8+
use JetBrains\PhpStorm\Deprecated;
89
use Symfony\Component\Console\Input\InputInterface;
910

1011
/**
1112
* Implement this interface when building a Drush sql-sanitize plugin.
1213
*/
14+
#[Deprecated(reason: 'Implement an event listener instead.')]
1315
interface SanitizePluginInterface
1416
{
1517
/**

src/Event/SanitizeConfirmsEvent.php

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
namespace Drush\Event;
4+
5+
use Symfony\Component\Console\Input\InputInterface;
6+
use Symfony\Contracts\EventDispatcher\Event;
7+
8+
/*
9+
* A custom event, for prompting the user about candidate sanitize operations.
10+
*
11+
* Listeners should add their confirm messages via addMessage().
12+
*/
13+
14+
final class SanitizeConfirmsEvent extends Event
15+
{
16+
public function __construct(
17+
protected InputInterface $input,
18+
protected array $messages = [],
19+
) {
20+
}
21+
22+
public function setInput(InputInterface $input): void
23+
{
24+
$this->input = $input;
25+
}
26+
27+
public function getInput(): InputInterface
28+
{
29+
return $this->input;
30+
}
31+
32+
public function addMessage(string $message): self
33+
{
34+
$this->messages[] = $message;
35+
return $this;
36+
}
37+
38+
public function getMessages(): array
39+
{
40+
return $this->messages;
41+
}
42+
43+
public function setMessages(array $messages): self
44+
{
45+
$this->messages = $messages;
46+
return $this;
47+
}
48+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Drush\Listeners\sanitize;
6+
7+
use Drupal\Core\Database\Connection;
8+
use Drupal\Core\Database\Query\SelectInterface;
9+
use Drupal\Core\Entity\EntityTypeManagerInterface;
10+
use Drupal\Core\Password\PasswordInterface;
11+
use Drush\Commands\AutowireTrait;
12+
use Drush\Commands\sql\sanitize\SanitizeCommand;
13+
use Drush\Event\ConsoleDefinitionsEvent;
14+
use Drush\Event\SanitizeConfirmsEvent;
15+
use Drush\Sql\SqlBase;
16+
use Drush\Utils\StringUtils;
17+
use Psr\Log\LoggerInterface;
18+
use Symfony\Component\Console\Event\ConsoleTerminateEvent;
19+
use Symfony\Component\Console\Input\InputOption;
20+
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
21+
22+
/**
23+
* Sanitize emails and passwords. This also an example of how to write a
24+
* database sanitizer for sql:sync.
25+
*/
26+
#[AsEventListener(method: 'onDefinition')]
27+
#[AsEventListener(method: 'onSanitizeConfirm')]
28+
#[AsEventListener(method: 'onConsoleTerminate')]
29+
final class SanitizeUserTableListener
30+
{
31+
use AutowireTrait;
32+
33+
public function __construct(
34+
protected Connection $database,
35+
protected PasswordInterface $passwordHasher,
36+
protected EntityTypeManagerInterface $entityTypeManager,
37+
protected LoggerInterface $logger,
38+
) {
39+
}
40+
41+
public function onDefinition(ConsoleDefinitionsEvent $event): void
42+
{
43+
foreach ($event->getApplication()->all() as $id => $command) {
44+
if ($command->getName() === SanitizeCommand::NAME) {
45+
$command->addOption(
46+
'sanitize-email',
47+
null,
48+
InputOption::VALUE_REQUIRED,
49+
'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>.',
50+
51+
)
52+
->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.')
53+
->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.');
54+
}
55+
}
56+
}
57+
58+
public function onSanitizeConfirm(SanitizeConfirmsEvent $event): void
59+
{
60+
$options = $event->getInput()->getOptions();
61+
if ($this->isEnabled($options['sanitize-password'])) {
62+
$event->addMessage(dt('Sanitize user passwords.'));
63+
}
64+
if ($this->isEnabled($options['sanitize-email'])) {
65+
$event->addMessage(dt('Sanitize user emails.'));
66+
}
67+
if (in_array('ignored-roles', $options)) {
68+
$event->addMessage(dt('Preserve user emails and passwords for the specified roles.'));
69+
}
70+
}
71+
72+
public function onConsoleTerminate(ConsoleTerminateEvent $event): void
73+
{
74+
$options = $event->getInput()->getOptions();
75+
$query = $this->database->update('users_field_data')->condition('uid', 0, '>');
76+
$messages = [];
77+
78+
// Sanitize passwords.
79+
if ($this->isEnabled($options['sanitize-password'])) {
80+
$password = $options['sanitize-password'];
81+
if (is_null($password)) {
82+
$password = StringUtils::generatePassword();
83+
}
84+
85+
// Mimic Drupal's /scripts/password-hash.sh
86+
$hash = $this->passwordHasher->hash($password);
87+
$query->fields(['pass' => $hash]);
88+
$messages[] = dt('User passwords sanitized.');
89+
}
90+
91+
// Sanitize email addresses.
92+
if ($this->isEnabled($options['sanitize-email'])) {
93+
if (str_contains($options['sanitize-email'], '%')) {
94+
// We need a different sanitization query for MSSQL, Postgres and Mysql.
95+
$sql = SqlBase::create($event->getInput()->getOptions());
96+
$db_driver = $sql->scheme();
97+
if ($db_driver === 'pgsql') {
98+
$email_map = ['%uid' => "' || uid || '", '%mail' => "' || replace(mail, '@', '_') || '", '%name' => "' || replace(name, ' ', '_') || '"];
99+
$new_mail = "'" . str_replace(array_keys($email_map), array_values($email_map), $options['sanitize-email']) . "'";
100+
} elseif ($db_driver === 'mssql') {
101+
$email_map = ['%uid' => "' + uid + '", '%mail' => "' + replace(mail, '@', '_') + '", '%name' => "' + replace(name, ' ', '_') + '"];
102+
$new_mail = "'" . str_replace(array_keys($email_map), array_values($email_map), $options['sanitize-email']) . "'";
103+
} else {
104+
$email_map = ['%uid' => "', uid, '", '%mail' => "', replace(mail, '@', '_'), '", '%name' => "', replace(name, ' ', '_'), '"];
105+
$new_mail = "concat('" . str_replace(array_keys($email_map), array_values($email_map), $options['sanitize-email']) . "')";
106+
}
107+
$query->expression('mail', $new_mail);
108+
$query->expression('init', $new_mail);
109+
} else {
110+
$query->fields(['mail' => $options['sanitize-email']]);
111+
}
112+
$messages[] = dt('User emails sanitized.');
113+
}
114+
115+
if (!empty($options['ignored-roles'])) {
116+
$roles = explode(',', $options['ignored-roles']);
117+
/** @var SelectInterface $roles_query */
118+
$roles_query = $this->database->select('user__roles', 'ur');
119+
$roles_query
120+
->condition('roles_target_id', $roles, 'IN')
121+
->fields('ur', ['entity_id']);
122+
$roles_query_results = $roles_query->execute();
123+
$ignored_users = $roles_query_results->fetchCol();
124+
125+
if (!empty($ignored_users)) {
126+
$query->condition('uid', $ignored_users, 'NOT IN');
127+
$messages[] = dt('User emails and passwords for the specified roles preserved.');
128+
}
129+
}
130+
131+
if ($messages) {
132+
$query->execute();
133+
$this->entityTypeManager->getStorage('user')->resetCache();
134+
foreach ($messages as $message) {
135+
$this->logger->success($message);
136+
}
137+
}
138+
}
139+
140+
/**
141+
* Test an option value to see if it is disabled.
142+
*/
143+
protected function isEnabled(?string $value): bool
144+
{
145+
return $value != 'no' && $value != '0';
146+
}
147+
}

src/Runtime/ServiceManager.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
use Symfony\Component\Console\Command\Command;
3535
use Symfony\Component\Console\ConsoleEvents;
3636
use Symfony\Component\Console\Event\ConsoleCommandEvent;
37+
use Symfony\Component\Console\Event\ConsoleTerminateEvent;
3738
use Symfony\Component\Console\Input\InputAwareInterface;
3839
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
3940

@@ -438,9 +439,11 @@ public function addListeners(iterable $classes, ContainerInterface $drushContain
438439
} else {
439440
throw new \Exception('Event listener method must have a single parameter with a type hint.');
440441
}
441-
if ($eventName == ConsoleCommandEvent::class) {
442-
$eventName = ConsoleEvents::COMMAND;
443-
}
442+
$eventName = match ($eventName) {
443+
ConsoleCommandEvent::class => ConsoleEvents::COMMAND,
444+
ConsoleTerminateEvent::class => ConsoleEvents::TERMINATE,
445+
default => $eventName,
446+
};
444447
Drush::getContainer()->get('eventDispatcher')->addListener($eventName, $instance->$method(...), $priority);
445448
}
446449
}

0 commit comments

Comments
 (0)