Passed
Push — master ( e80e0d...c605ef )
by Blizzz
15:00 queued 15s
created

UpdateUUID::handleUpdatesByEntryId()   A

Complexity

Conditions 4
Paths 6

Size

Total Lines 15
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 4
eloc 11
c 1
b 0
f 1
nc 6
nop 2
dl 0
loc 15
rs 9.9
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * @copyright Copyright (c) 2021 Arthur Schiwon <[email protected]>
7
 *
8
 * @author Arthur Schiwon <[email protected]>
9
 * @author Côme Chilliet <[email protected]>
10
 *
11
 * @license GNU AGPL version 3 or any later version
12
 *
13
 * This program is free software: you can redistribute it and/or modify
14
 * it under the terms of the GNU Affero General Public License as
15
 * published by the Free Software Foundation, either version 3 of the
16
 * License, or (at your option) any later version.
17
 *
18
 * This program is distributed in the hope that it will be useful,
19
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
20
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
21
 * GNU Affero General Public License for more details.
22
 *
23
 * You should have received a copy of the GNU Affero General Public License
24
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
25
 *
26
 */
27
28
namespace OCA\User_LDAP\Command;
29
30
use OCA\User_LDAP\Access;
31
use OCA\User_LDAP\Group_Proxy;
32
use OCA\User_LDAP\Mapping\AbstractMapping;
33
use OCA\User_LDAP\Mapping\GroupMapping;
34
use OCA\User_LDAP\Mapping\UserMapping;
35
use OCA\User_LDAP\User_Proxy;
36
use Psr\Log\LoggerInterface;
37
use Symfony\Component\Console\Command\Command;
38
use Symfony\Component\Console\Helper\ProgressBar;
39
use Symfony\Component\Console\Input\InputInterface;
40
use Symfony\Component\Console\Input\InputOption;
41
use Symfony\Component\Console\Output\OutputInterface;
42
use function sprintf;
43
44
class UuidUpdateReport {
45
	const UNCHANGED = 0;
46
	const UNKNOWN = 1;
47
	const UNREADABLE = 2;
48
	const UPDATED = 3;
49
	const UNWRITABLE = 4;
50
	const UNMAPPED = 5;
51
52
	public $id = '';
53
	public $dn = '';
54
	public $isUser = true;
55
	public $state = self::UNCHANGED;
56
	public $oldUuid = '';
57
	public $newUuid = '';
58
59
	public function __construct(string $id, string $dn, bool $isUser, int $state, string $oldUuid = '', string $newUuid = '') {
60
		$this->id = $id;
61
		$this->dn = $dn;
62
		$this->isUser = $isUser;
63
		$this->state = $state;
64
		$this->oldUuid = $oldUuid;
65
		$this->newUuid = $newUuid;
66
	}
67
}
68
69
class UpdateUUID extends Command {
70
	/** @var UserMapping */
71
	private $userMapping;
72
	/** @var GroupMapping */
73
	private $groupMapping;
74
	/** @var User_Proxy */
75
	private $userProxy;
76
	/** @var Group_Proxy */
77
	private $groupProxy;
78
	/** @var array<UuidUpdateReport[]> */
79
	protected $reports = [];
80
	/** @var LoggerInterface */
81
	private $logger;
82
	/** @var bool */
83
	private $dryRun = false;
84
85
	public function __construct(UserMapping $userMapping, GroupMapping $groupMapping, User_Proxy $userProxy, Group_Proxy $groupProxy, LoggerInterface $logger) {
86
		$this->userMapping = $userMapping;
87
		$this->groupMapping = $groupMapping;
88
		$this->userProxy = $userProxy;
89
		$this->groupProxy = $groupProxy;
90
		$this->logger = $logger;
91
		$this->reports = [
92
			UuidUpdateReport::UPDATED => [],
93
			UuidUpdateReport::UNKNOWN => [],
94
			UuidUpdateReport::UNREADABLE => [],
95
			UuidUpdateReport::UNWRITABLE => [],
96
			UuidUpdateReport::UNMAPPED => [],
97
		];
98
		parent::__construct();
99
	}
100
101
	protected function configure(): void {
102
		$this
103
			->setName('ldap:update-uuid')
104
			->setDescription('Attempts to update UUIDs of user and group entries. By default, the command attempts to update UUIDs that have been invalidated by a migration step.')
105
			->addOption(
106
				'all',
107
				null,
108
				InputOption::VALUE_NONE,
109
				'updates every user and group. All other options are ignored.'
110
			)
111
			->addOption(
112
				'userId',
113
				null,
114
				InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
115
				'a user ID to update'
116
			)
117
			->addOption(
118
				'groupId',
119
				null,
120
				InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
121
				'a group ID to update'
122
			)
123
			->addOption(
124
				'dn',
125
				null,
126
				InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
127
				'a DN to update'
128
			)
129
			->addOption(
130
				'dry-run',
131
				null,
132
				InputOption::VALUE_NONE,
133
				'UUIDs will not be updated in the database'
134
			)
135
		;
136
	}
137
138
	protected function execute(InputInterface $input, OutputInterface $output): int {
139
		$this->dryRun = $input->getOption('dry-run');
140
		$entriesToUpdate = $this->estimateNumberOfUpdates($input);
141
		$progress = new ProgressBar($output);
142
		$progress->start($entriesToUpdate);
143
		foreach($this->handleUpdates($input) as $_) {
144
			$progress->advance();
145
		}
146
		$progress->finish();
147
		$output->writeln('');
148
		$this->printReport($output);
149
		return count($this->reports[UuidUpdateReport::UNMAPPED]) === 0
150
			&& count($this->reports[UuidUpdateReport::UNREADABLE]) === 0
151
			&& count($this->reports[UuidUpdateReport::UNWRITABLE]) === 0
152
			? 0
153
			: 1;
154
	}
155
156
	protected function printReport(OutputInterface $output): void {
157
		if ($output->isQuiet()) {
158
			return;
159
		}
160
161
		if (count($this->reports[UuidUpdateReport::UPDATED]) === 0) {
162
			$output->writeln('<info>No record was updated.</info>');
163
		} else {
164
			$output->writeln(sprintf('<info>%d record(s) were updated.</info>', count($this->reports[UuidUpdateReport::UPDATED])));
165
			if ($output->isVerbose()) {
166
				/** @var UuidUpdateReport $report */
167
				foreach ($this->reports[UuidUpdateReport::UPDATED] as $report) {
168
					$output->writeln(sprintf('  %s had their old UUID %s updated to %s', $report->id, $report->oldUuid, $report->newUuid));
169
				}
170
				$output->writeln('');
171
			}
172
		}
173
174
		if (count($this->reports[UuidUpdateReport::UNMAPPED]) > 0) {
175
			$output->writeln(sprintf('<error>%d provided IDs were not mapped. These were:</error>', count($this->reports[UuidUpdateReport::UNMAPPED])));
176
			/** @var UuidUpdateReport $report */
177
			foreach ($this->reports[UuidUpdateReport::UNMAPPED] as $report) {
178
				if (!empty($report->id)) {
179
					$output->writeln(sprintf('  %s: %s',
180
						$report->isUser ? 'User' : 'Group', $report->id));
181
				} else if (!empty($report->dn)) {
182
					$output->writeln(sprintf('  DN: %s', $report->dn));
183
				}
184
			}
185
			$output->writeln('');
186
		}
187
188
		if (count($this->reports[UuidUpdateReport::UNKNOWN]) > 0) {
189
			$output->writeln(sprintf('<info>%d provided IDs were unknown on LDAP.</info>', count($this->reports[UuidUpdateReport::UNKNOWN])));
190
			if ($output->isVerbose()) {
191
				/** @var UuidUpdateReport $report */
192
				foreach ($this->reports[UuidUpdateReport::UNKNOWN] as $report) {
193
					$output->writeln(sprintf('  %s: %s',$report->isUser ? 'User' : 'Group', $report->id));
194
				}
195
				$output->writeln(PHP_EOL . 'Old users can be removed along with their data per occ user:delete.' . PHP_EOL);
196
			}
197
		}
198
199
		if (count($this->reports[UuidUpdateReport::UNREADABLE]) > 0) {
200
			$output->writeln(sprintf('<error>For %d records, the UUID could not be read. Double-check your configuration.</error>', count($this->reports[UuidUpdateReport::UNREADABLE])));
201
			if ($output->isVerbose()) {
202
				/** @var UuidUpdateReport $report */
203
				foreach ($this->reports[UuidUpdateReport::UNREADABLE] as $report) {
204
					$output->writeln(sprintf('  %s: %s',$report->isUser ? 'User' : 'Group', $report->id));
205
				}
206
			}
207
		}
208
209
		if (count($this->reports[UuidUpdateReport::UNWRITABLE]) > 0) {
210
			$output->writeln(sprintf('<error>For %d records, the UUID could not be saved to database. Double-check your configuration.</error>', count($this->reports[UuidUpdateReport::UNWRITABLE])));
211
			if ($output->isVerbose()) {
212
				/** @var UuidUpdateReport $report */
213
				foreach ($this->reports[UuidUpdateReport::UNWRITABLE] as $report) {
214
					$output->writeln(sprintf('  %s: %s',$report->isUser ? 'User' : 'Group', $report->id));
215
				}
216
			}
217
		}
218
	}
219
220
	protected function handleUpdates(InputInterface $input): \Generator {
221
		if ($input->getOption('all')) {
222
			foreach($this->handleMappingBasedUpdates(false) as $_) {
223
				yield;
224
			}
225
		} else if ($input->getOption('userId')
226
			|| $input->getOption('groupId')
227
			|| $input->getOption('dn')
228
		) {
229
			foreach($this->handleUpdatesByUserId($input->getOption('userId')) as $_) {
230
				yield;
231
			}
232
			foreach($this->handleUpdatesByGroupId($input->getOption('groupId')) as $_) {
233
				yield;
234
			}
235
			foreach($this->handleUpdatesByDN($input->getOption('dn')) as $_) {
236
				yield;
237
			}
238
		} else {
239
			foreach($this->handleMappingBasedUpdates(true) as $_) {
240
				yield;
241
			}
242
		}
243
	}
244
245
	protected function handleUpdatesByUserId(array $userIds): \Generator {
246
		foreach($this->handleUpdatesByEntryId($userIds, $this->userMapping) as $_) {
247
			yield;
248
		}
249
	}
250
251
	protected function handleUpdatesByGroupId(array $groupIds): \Generator {
252
		foreach($this->handleUpdatesByEntryId($groupIds, $this->groupMapping) as $_) {
253
			yield;
254
		}
255
	}
256
257
	protected function handleUpdatesByDN(array $dns): \Generator {
258
		$userList = $groupList = [];
259
		while ($dn = array_pop($dns)) {
260
			$uuid = $this->userMapping->getUUIDByDN($dn);
261
			if ($uuid) {
262
				$id = $this->userMapping->getNameByDN($dn);
263
				$userList[] = ['name' => $id, 'uuid' => $uuid];
264
				continue;
265
			}
266
			$uuid = $this->groupMapping->getUUIDByDN($dn);
267
			if ($uuid) {
268
				$id = $this->groupMapping->getNameByDN($dn);
269
				$groupList[] = ['name' => $id, 'uuid' => $uuid];
270
				continue;
271
			}
272
			$this->reports[UuidUpdateReport::UNMAPPED][] = new UuidUpdateReport('', $dn, true, UuidUpdateReport::UNMAPPED);
273
			yield;
274
		}
275
		foreach($this->handleUpdatesByList($this->userMapping, $userList) as $_) {
276
			yield;
277
		}
278
		foreach($this->handleUpdatesByList($this->groupMapping, $groupList) as $_) {
279
			yield;
280
		}
281
	}
282
283
	protected function handleUpdatesByEntryId(array $ids, AbstractMapping $mapping): \Generator {
284
		$isUser = $mapping instanceof UserMapping;
285
		$list = [];
286
		while ($id = array_pop($ids)) {
287
			if(!$dn = $mapping->getDNByName($id)) {
288
				$this->reports[UuidUpdateReport::UNMAPPED][] = new UuidUpdateReport($id, '', $isUser, UuidUpdateReport::UNMAPPED);
289
				yield;
290
				continue;
291
			}
292
			// Since we know it was mapped the UUID is populated
293
			$uuid = $mapping->getUUIDByDN($dn);
294
			$list[] = ['name' => $id, 'uuid' => $uuid];
295
		}
296
		foreach($this->handleUpdatesByList($mapping, $list) as $_) {
297
			yield;
298
		}
299
	}
300
301
	protected function handleMappingBasedUpdates(bool $invalidatedOnly): \Generator {
302
		$limit = 1000;
303
		/** @var AbstractMapping $mapping*/
304
		foreach([$this->userMapping, $this->groupMapping] as $mapping) {
305
			$offset = 0;
306
			do {
307
				$list = $mapping->getList($offset, $limit, $invalidatedOnly);
308
				$offset += $limit;
309
310
				foreach($this->handleUpdatesByList($mapping, $list) as $tick) {
311
					yield; // null, for it only advances progress counter
312
				}
313
			} while (count($list) === $limit);
314
		}
315
	}
316
317
	protected function handleUpdatesByList(AbstractMapping $mapping, array $list): \Generator {
318
		if ($mapping instanceof UserMapping) {
319
			$isUser = true;
320
			$backendProxy = $this->userProxy;
321
		} else {
322
			$isUser = false;
323
			$backendProxy = $this->groupProxy;
324
		}
325
326
		foreach ($list as $row) {
327
			$access = $backendProxy->getLDAPAccess($row['name']);
328
			if ($access instanceof Access
329
				&& $dn = $mapping->getDNByName($row['name']))
330
			{
331
				if ($uuid = $access->getUUID($dn, $isUser)) {
332
					if ($uuid !== $row['uuid']) {
333
						if ($this->dryRun || $mapping->setUUIDbyDN($uuid, $dn)) {
334
							$this->reports[UuidUpdateReport::UPDATED][]
335
								= new UuidUpdateReport($row['name'], $dn, $isUser, UuidUpdateReport::UPDATED, $row['uuid'], $uuid);
336
						} else {
337
							$this->reports[UuidUpdateReport::UNWRITABLE][]
338
								= new UuidUpdateReport($row['name'], $dn, $isUser, UuidUpdateReport::UNWRITABLE, $row['uuid'], $uuid);
339
						}
340
						$this->logger->info('UUID of {id} was updated from {from} to {to}',
341
							[
342
								'appid' => 'user_ldap',
343
								'id' => $row['name'],
344
								'from' => $row['uuid'],
345
								'to' => $uuid,
346
							]
347
						);
348
					}
349
				} else {
350
					$this->reports[UuidUpdateReport::UNREADABLE][] = new UuidUpdateReport($row['name'], $dn, $isUser, UuidUpdateReport::UNREADABLE);
351
				}
352
			} else {
353
				$this->reports[UuidUpdateReport::UNKNOWN][] = new UuidUpdateReport($row['name'], '', $isUser, UuidUpdateReport::UNKNOWN);
354
			}
355
			yield; // null, for it only advances progress counter
356
		}
357
	}
358
359
	protected function estimateNumberOfUpdates(InputInterface $input): int {
360
		if ($input->getOption('all')) {
361
			return $this->userMapping->count() + $this->groupMapping->count();
362
		} else if ($input->getOption('userId')
363
			|| $input->getOption('groupId')
364
			|| $input->getOption('dn')
365
		) {
366
			return count($input->getOption('userId'))
367
				+ count($input->getOption('groupId'))
368
				+ count($input->getOption('dn'));
369
		} else {
370
			return $this->userMapping->countInvalidated() + $this->groupMapping->countInvalidated();
371
		}
372
	}
373
374
}
375