You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

UpdateUUID.php 12KB

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