diff --git a/apps/settings/composer/composer/autoload_classmap.php b/apps/settings/composer/composer/autoload_classmap.php index 0b088ff36026a..e88158ae2c8d8 100644 --- a/apps/settings/composer/composer/autoload_classmap.php +++ b/apps/settings/composer/composer/autoload_classmap.php @@ -56,6 +56,7 @@ 'OCA\\Settings\\Settings\\Personal\\Security\\TwoFactor' => $baseDir . '/../lib/Settings/Personal/Security/TwoFactor.php', 'OCA\\Settings\\Settings\\Personal\\Security\\WebAuthn' => $baseDir . '/../lib/Settings/Personal/Security/WebAuthn.php', 'OCA\\Settings\\Settings\\Personal\\ServerDevNotice' => $baseDir . '/../lib/Settings/Personal/ServerDevNotice.php', + 'OCA\\Settings\\SetupChecks\\LdapInvalidUuids' => $baseDir . '/../lib/SetupChecks/LdapInvalidUuids.php', 'OCA\\Settings\\SetupChecks\\LegacySSEKeyFormat' => $baseDir . '/../lib/SetupChecks/LegacySSEKeyFormat.php', 'OCA\\Settings\\SetupChecks\\PhpDefaultCharset' => $baseDir . '/../lib/SetupChecks/PhpDefaultCharset.php', 'OCA\\Settings\\SetupChecks\\PhpOutputBuffering' => $baseDir . '/../lib/SetupChecks/PhpOutputBuffering.php', diff --git a/apps/settings/composer/composer/autoload_static.php b/apps/settings/composer/composer/autoload_static.php index 457e700f6a539..9157773d2b307 100644 --- a/apps/settings/composer/composer/autoload_static.php +++ b/apps/settings/composer/composer/autoload_static.php @@ -71,6 +71,7 @@ class ComposerStaticInitSettings 'OCA\\Settings\\Settings\\Personal\\Security\\TwoFactor' => __DIR__ . '/..' . '/../lib/Settings/Personal/Security/TwoFactor.php', 'OCA\\Settings\\Settings\\Personal\\Security\\WebAuthn' => __DIR__ . '/..' . '/../lib/Settings/Personal/Security/WebAuthn.php', 'OCA\\Settings\\Settings\\Personal\\ServerDevNotice' => __DIR__ . '/..' . '/../lib/Settings/Personal/ServerDevNotice.php', + 'OCA\\Settings\\SetupChecks\\LdapInvalidUuids' => __DIR__ . '/..' . '/../lib/SetupChecks/LdapInvalidUuids.php', 'OCA\\Settings\\SetupChecks\\LegacySSEKeyFormat' => __DIR__ . '/..' . '/../lib/SetupChecks/LegacySSEKeyFormat.php', 'OCA\\Settings\\SetupChecks\\PhpDefaultCharset' => __DIR__ . '/..' . '/../lib/SetupChecks/PhpDefaultCharset.php', 'OCA\\Settings\\SetupChecks\\PhpOutputBuffering' => __DIR__ . '/..' . '/../lib/SetupChecks/PhpOutputBuffering.php', diff --git a/apps/settings/lib/Controller/CheckSetupController.php b/apps/settings/lib/Controller/CheckSetupController.php index 1df8dda989756..4ce5ef6ae4a2a 100644 --- a/apps/settings/lib/Controller/CheckSetupController.php +++ b/apps/settings/lib/Controller/CheckSetupController.php @@ -54,10 +54,12 @@ use OC\IntegrityCheck\Checker; use OC\Lock\NoopLockingProvider; use OC\MemoryInfo; +use OCA\Settings\SetupChecks\LdapInvalidUuids; use OCA\Settings\SetupChecks\LegacySSEKeyFormat; use OCA\Settings\SetupChecks\PhpDefaultCharset; use OCA\Settings\SetupChecks\PhpOutputBuffering; use OCA\Settings\SetupChecks\SupportedDatabase; +use OCP\App\IAppManager; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\DataDisplayResponse; use OCP\AppFramework\Http\DataResponse; @@ -69,6 +71,7 @@ use OCP\IL10N; use OCP\ILogger; use OCP\IRequest; +use OCP\IServerContainer; use OCP\IURLGenerator; use OCP\Lock\ILockingProvider; use OCP\Security\ISecureRandom; @@ -104,6 +107,10 @@ class CheckSetupController extends Controller { private $iniGetWrapper; /** @var IDBConnection */ private $connection; + /** @var IAppManager */ + private $appManager; + /** @var IServerContainer */ + private $serverContainer; public function __construct($AppName, IRequest $request, @@ -120,7 +127,10 @@ public function __construct($AppName, MemoryInfo $memoryInfo, ISecureRandom $secureRandom, IniGetWrapper $iniGetWrapper, - IDBConnection $connection) { + IDBConnection $connection, + IAppManager $appManager, + IServerContainer $serverContainer + ) { parent::__construct($AppName, $request); $this->config = $config; $this->clientService = $clientService; @@ -136,6 +146,8 @@ public function __construct($AppName, $this->secureRandom = $secureRandom; $this->iniGetWrapper = $iniGetWrapper; $this->connection = $connection; + $this->appManager = $appManager; + $this->serverContainer = $serverContainer; } /** @@ -721,6 +733,8 @@ public function check() { $phpOutputBuffering = new PhpOutputBuffering(); $legacySSEKeyFormat = new LegacySSEKeyFormat($this->l10n, $this->config, $this->urlGenerator); $supportedDatabases = new SupportedDatabase($this->l10n, $this->connection); + $ldapInvalidUuids = new LdapInvalidUuids($this->appManager, $this->l10n, $this->serverContainer); + return new DataResponse( [ 'isGetenvServerWorking' => !empty(getenv('PATH')), @@ -766,6 +780,7 @@ public function check() { PhpOutputBuffering::class => ['pass' => $phpOutputBuffering->run(), 'description' => $phpOutputBuffering->description(), 'severity' => $phpOutputBuffering->severity()], LegacySSEKeyFormat::class => ['pass' => $legacySSEKeyFormat->run(), 'description' => $legacySSEKeyFormat->description(), 'severity' => $legacySSEKeyFormat->severity(), 'linkToDocumentation' => $legacySSEKeyFormat->linkToDocumentation()], SupportedDatabase::class => ['pass' => $supportedDatabases->run(), 'description' => $supportedDatabases->description(), 'severity' => $supportedDatabases->severity()], + LdapInvalidUuids::class => ['pass' => $ldapInvalidUuids->run(), 'description' => $ldapInvalidUuids->description(), 'severity' => $ldapInvalidUuids->severity()], ] ); } diff --git a/apps/settings/lib/SetupChecks/LdapInvalidUuids.php b/apps/settings/lib/SetupChecks/LdapInvalidUuids.php new file mode 100644 index 0000000000000..11b0105cada84 --- /dev/null +++ b/apps/settings/lib/SetupChecks/LdapInvalidUuids.php @@ -0,0 +1,69 @@ + + * + * @author Arthur Schiwon + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Settings\SetupChecks; + +use OCA\User_LDAP\Mapping\GroupMapping; +use OCA\User_LDAP\Mapping\UserMapping; +use OCP\App\IAppManager; +use OCP\IL10N; +use OCP\IServerContainer; + +class LdapInvalidUuids { + + /** @var IAppManager */ + private $appManager; + /** @var IL10N */ + private $l10n; + /** @var IServerContainer */ + private $server; + + public function __construct(IAppManager $appManager, IL10N $l10n, IServerContainer $server) { + $this->appManager = $appManager; + $this->l10n = $l10n; + $this->server = $server; + } + + public function description(): string { + return $this->l10n->t('Invalid UUIDs of LDAP users or groups have been found. Please review your "Override UUID detection" settings in the Expert part of the LDAP configuration and use "occ ldap:update-uuid" to update them.'); + } + + public function severity(): string { + return 'warning'; + } + + public function run(): bool { + if (!$this->appManager->isEnabledForUser('user_ldap')) { + return true; + } + /** @var UserMapping $userMapping */ + $userMapping = $this->server->get(UserMapping::class); + /** @var GroupMapping $groupMapping */ + $groupMapping = $this->server->get(GroupMapping::class); + return count($userMapping->getList(0, 1, true)) === 0 + && count($groupMapping->getList(0, 1, true)) === 0; + } +} diff --git a/apps/settings/tests/Controller/CheckSetupControllerTest.php b/apps/settings/tests/Controller/CheckSetupControllerTest.php index d950879a765a2..59c3816a0be12 100644 --- a/apps/settings/tests/Controller/CheckSetupControllerTest.php +++ b/apps/settings/tests/Controller/CheckSetupControllerTest.php @@ -42,6 +42,7 @@ use OC\MemoryInfo; use OC\Security\SecureRandom; use OCA\Settings\Controller\CheckSetupController; +use OCP\App\IAppManager; use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataDisplayResponse; use OCP\AppFramework\Http\DataResponse; @@ -53,6 +54,7 @@ use OCP\IL10N; use OCP\ILogger; use OCP\IRequest; +use OCP\IServerContainer; use OCP\IURLGenerator; use OCP\Lock\ILockingProvider; use PHPUnit\Framework\MockObject\MockObject; @@ -99,6 +101,10 @@ class CheckSetupControllerTest extends TestCase { private $iniGetWrapper; /** @var IDBConnection|\PHPUnit\Framework\MockObject\MockObject */ private $connection; + /** @var IAppManager|MockObject */ + private $appManager; + /** @var IServerContainer|MockObject */ + private $serverContainer; /** * Holds a list of directories created during tests. @@ -141,6 +147,8 @@ protected function setUp(): void { $this->iniGetWrapper = $this->getMockBuilder(IniGetWrapper::class)->getMock(); $this->connection = $this->getMockBuilder(IDBConnection::class) ->disableOriginalConstructor()->getMock(); + $this->appManager = $this->createMock(IAppManager::class); + $this->serverContainer = $this->createMock(IServerContainer::class); $this->checkSetupController = $this->getMockBuilder(CheckSetupController::class) ->setConstructorArgs([ 'settings', @@ -159,6 +167,8 @@ protected function setUp(): void { $this->secureRandom, $this->iniGetWrapper, $this->connection, + $this->appManager, + $this->serverContainer, ]) ->setMethods([ 'isReadOnlyConfig', @@ -616,6 +626,7 @@ public function testCheck() { 'OCA\Settings\SetupChecks\PhpOutputBuffering' => ['pass' => true, 'description' => 'PHP configuration option output_buffering must be disabled', 'severity' => 'error'], 'OCA\Settings\SetupChecks\LegacySSEKeyFormat' => ['pass' => true, 'description' => 'The old server-side-encryption format is enabled. We recommend disabling this.', 'severity' => 'warning', 'linkToDocumentation' => ''], 'OCA\Settings\SetupChecks\SupportedDatabase' => ['pass' => true, 'description' => '', 'severity' => 'info'], + \OCA\Settings\SetupChecks\LdapInvalidUuids::class => ['pass' => true, 'description' => 'Invalid UUIDs of LDAP users or groups have been found. Please review your "Override UUID detection" settings in the Expert part of the LDAP configuration and use "occ ldap:update-uuid" to update them.', 'severity' => 'warning'], ] ); $this->assertEquals($expected, $this->checkSetupController->check()); @@ -640,6 +651,8 @@ public function testIsPHPMailerUsed() { $this->secureRandom, $this->iniGetWrapper, $this->connection, + $this->appManager, + $this->serverContainer ]) ->setMethods(null)->getMock(); @@ -675,6 +688,8 @@ public function testGetCurlVersion() { $this->secureRandom, $this->iniGetWrapper, $this->connection, + $this->appManager, + $this->serverContainer ]) ->setMethods(null)->getMock(); @@ -1444,7 +1459,9 @@ public function testIsMysqlUsedWithoutUTF8MB4(string $db, bool $useUTF8MB4, bool $this->memoryInfo, $this->secureRandom, $this->iniGetWrapper, - $this->connection + $this->connection, + $this->appManager, + $this->serverContainer ); $this->assertSame($expected, $this->invokePrivate($checkSetupController, 'isMysqlUsedWithoutUTF8MB4')); @@ -1494,7 +1511,9 @@ public function testIsEnoughTempSpaceAvailableIfS3PrimaryStorageIsUsed(string $m $this->memoryInfo, $this->secureRandom, $this->iniGetWrapper, - $this->connection + $this->connection, + $this->appManager, + $this->serverContainer ); $this->assertSame($expected, $this->invokePrivate($checkSetupController, 'isEnoughTempSpaceAvailableIfS3PrimaryStorageIsUsed')); diff --git a/apps/user_ldap/appinfo/register_command.php b/apps/user_ldap/appinfo/register_command.php index fc8f6eba5bc21..44969bfa8f349 100644 --- a/apps/user_ldap/appinfo/register_command.php +++ b/apps/user_ldap/appinfo/register_command.php @@ -25,8 +25,11 @@ * */ +use OCA\User_LDAP\Command\UpdateUUID; +use OCA\User_LDAP\Group_Proxy; use OCA\User_LDAP\Helper; use OCA\User_LDAP\LDAP; +use OCA\User_LDAP\Mapping\GroupMapping; use OCA\User_LDAP\User_Proxy; use OCA\User_LDAP\Mapping\UserMapping; use OCA\User_LDAP\User\DeletedUsersIndex; @@ -35,19 +38,23 @@ $dbConnection = \OC::$server->getDatabaseConnection(); $userMapping = new UserMapping($dbConnection); +$groupMapping = \OC::$server->get(GroupMapping::class); $helper = new Helper(\OC::$server->getConfig()); $ocConfig = \OC::$server->getConfig(); +$activeConfigurationPrefixes = $helper->getServerConfigurationPrefixes(true); $uBackend = new User_Proxy( - $helper->getServerConfigurationPrefixes(true), + $activeConfigurationPrefixes, new LDAP(), $ocConfig, \OC::$server->getNotificationManager(), \OC::$server->getUserSession(), \OC::$server->query(UserPluginManager::class) ); +$groupBackend = new Group_Proxy($activeConfigurationPrefixes, new LDAP(), \OC::$server->get(\OCA\User_LDAP\GroupPluginManager::class)); $deletedUsersIndex = new DeletedUsersIndex( $ocConfig, $dbConnection, $userMapping ); +$logger = \OC::$server->get(\Psr\Log\LoggerInterface::class); $application->add(new OCA\User_LDAP\Command\ShowConfig($helper)); $application->add(new OCA\User_LDAP\Command\SetConfig()); @@ -66,3 +73,4 @@ $application->add(new OCA\User_LDAP\Command\CheckUser( $uBackend, $helper, $deletedUsersIndex, $userMapping) ); +$application->add(new UpdateUUID($userMapping, $groupMapping, $uBackend, $groupBackend, $logger)); diff --git a/apps/user_ldap/composer/composer/autoload_classmap.php b/apps/user_ldap/composer/composer/autoload_classmap.php index 387ee0f7adb66..1637bbc4e3b33 100644 --- a/apps/user_ldap/composer/composer/autoload_classmap.php +++ b/apps/user_ldap/composer/composer/autoload_classmap.php @@ -19,6 +19,8 @@ 'OCA\\User_LDAP\\Command\\ShowConfig' => $baseDir . '/../lib/Command/ShowConfig.php', 'OCA\\User_LDAP\\Command\\ShowRemnants' => $baseDir . '/../lib/Command/ShowRemnants.php', 'OCA\\User_LDAP\\Command\\TestConfig' => $baseDir . '/../lib/Command/TestConfig.php', + 'OCA\\User_LDAP\\Command\\UpdateUUID' => $baseDir . '/../lib/Command/UpdateUUID.php', + 'OCA\\User_LDAP\\Command\\UuidUpdateReport' => $baseDir . '/../lib/Command/UpdateUUID.php', 'OCA\\User_LDAP\\Configuration' => $baseDir . '/../lib/Configuration.php', 'OCA\\User_LDAP\\Connection' => $baseDir . '/../lib/Connection.php', 'OCA\\User_LDAP\\ConnectionFactory' => $baseDir . '/../lib/ConnectionFactory.php', diff --git a/apps/user_ldap/composer/composer/autoload_static.php b/apps/user_ldap/composer/composer/autoload_static.php index ee05a1e2fd0ba..aa0d1efd469d8 100644 --- a/apps/user_ldap/composer/composer/autoload_static.php +++ b/apps/user_ldap/composer/composer/autoload_static.php @@ -34,6 +34,8 @@ class ComposerStaticInitUser_LDAP 'OCA\\User_LDAP\\Command\\ShowConfig' => __DIR__ . '/..' . '/../lib/Command/ShowConfig.php', 'OCA\\User_LDAP\\Command\\ShowRemnants' => __DIR__ . '/..' . '/../lib/Command/ShowRemnants.php', 'OCA\\User_LDAP\\Command\\TestConfig' => __DIR__ . '/..' . '/../lib/Command/TestConfig.php', + 'OCA\\User_LDAP\\Command\\UpdateUUID' => __DIR__ . '/..' . '/../lib/Command/UpdateUUID.php', + 'OCA\\User_LDAP\\Command\\UuidUpdateReport' => __DIR__ . '/..' . '/../lib/Command/UpdateUUID.php', 'OCA\\User_LDAP\\Configuration' => __DIR__ . '/..' . '/../lib/Configuration.php', 'OCA\\User_LDAP\\Connection' => __DIR__ . '/..' . '/../lib/Connection.php', 'OCA\\User_LDAP\\ConnectionFactory' => __DIR__ . '/..' . '/../lib/ConnectionFactory.php', diff --git a/apps/user_ldap/lib/Access.php b/apps/user_ldap/lib/Access.php index 5c515d37a732d..b96cb25e631bc 100644 --- a/apps/user_ldap/lib/Access.php +++ b/apps/user_ldap/lib/Access.php @@ -1793,7 +1793,7 @@ private function detectUuidAttribute($dn, $isUser = true, $force = false, array * @param string $dn * @param bool $isUser * @param null $ldapRecord - * @return bool|string + * @return false|string * @throws ServerNotAvailableException */ public function getUUID($dn, $isUser = true, $ldapRecord = null) { diff --git a/apps/user_ldap/lib/Command/UpdateUUID.php b/apps/user_ldap/lib/Command/UpdateUUID.php new file mode 100644 index 0000000000000..ef8b15c5ac36e --- /dev/null +++ b/apps/user_ldap/lib/Command/UpdateUUID.php @@ -0,0 +1,372 @@ + + * + * @author Arthur Schiwon + * @author Côme Chilliet + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\User_LDAP\Command; + +use OCA\User_LDAP\Access; +use OCA\User_LDAP\Group_Proxy; +use OCA\User_LDAP\Mapping\AbstractMapping; +use OCA\User_LDAP\Mapping\GroupMapping; +use OCA\User_LDAP\Mapping\UserMapping; +use OCA\User_LDAP\User_Proxy; +use Psr\Log\LoggerInterface; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\ProgressBar; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use function sprintf; + +class UuidUpdateReport { + public const UNCHANGED = 0; + public const UNKNOWN = 1; + public const UNREADABLE = 2; + public const UPDATED = 3; + public const UNWRITABLE = 4; + public const UNMAPPED = 5; + + public $id = ''; + public $dn = ''; + public $isUser = true; + public $state = self::UNCHANGED; + public $oldUuid = ''; + public $newUuid = ''; + + public function __construct(string $id, string $dn, bool $isUser, int $state, string $oldUuid = '', string $newUuid = '') { + $this->id = $id; + $this->dn = $dn; + $this->isUser = $isUser; + $this->state = $state; + $this->oldUuid = $oldUuid; + $this->newUuid = $newUuid; + } +} + +class UpdateUUID extends Command { + /** @var UserMapping */ + private $userMapping; + /** @var GroupMapping */ + private $groupMapping; + /** @var User_Proxy */ + private $userProxy; + /** @var Group_Proxy */ + private $groupProxy; + /** @var array */ + protected $reports = []; + /** @var LoggerInterface */ + private $logger; + /** @var bool */ + private $dryRun = false; + + public function __construct(UserMapping $userMapping, GroupMapping $groupMapping, User_Proxy $userProxy, Group_Proxy $groupProxy, LoggerInterface $logger) { + $this->userMapping = $userMapping; + $this->groupMapping = $groupMapping; + $this->userProxy = $userProxy; + $this->groupProxy = $groupProxy; + $this->logger = $logger; + $this->reports = [ + UuidUpdateReport::UPDATED => [], + UuidUpdateReport::UNKNOWN => [], + UuidUpdateReport::UNREADABLE => [], + UuidUpdateReport::UNWRITABLE => [], + UuidUpdateReport::UNMAPPED => [], + ]; + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('ldap:update-uuid') + ->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.') + ->addOption( + 'all', + null, + InputOption::VALUE_NONE, + 'updates every user and group. All other options are ignored.' + ) + ->addOption( + 'userId', + null, + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'a user ID to update' + ) + ->addOption( + 'groupId', + null, + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'a group ID to update' + ) + ->addOption( + 'dn', + null, + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'a DN to update' + ) + ->addOption( + 'dry-run', + null, + InputOption::VALUE_NONE, + 'UUIDs will not be updated in the database' + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $this->dryRun = $input->getOption('dry-run'); + $entriesToUpdate = $this->estimateNumberOfUpdates($input); + $progress = new ProgressBar($output); + $progress->start($entriesToUpdate); + foreach ($this->handleUpdates($input) as $_) { + $progress->advance(); + } + $progress->finish(); + $output->writeln(''); + $this->printReport($output); + return count($this->reports[UuidUpdateReport::UNMAPPED]) === 0 + && count($this->reports[UuidUpdateReport::UNREADABLE]) === 0 + && count($this->reports[UuidUpdateReport::UNWRITABLE]) === 0 + ? 0 + : 1; + } + + protected function printReport(OutputInterface $output): void { + if ($output->isQuiet()) { + return; + } + + if (count($this->reports[UuidUpdateReport::UPDATED]) === 0) { + $output->writeln('No record was updated.'); + } else { + $output->writeln(sprintf('%d record(s) were updated.', count($this->reports[UuidUpdateReport::UPDATED]))); + if ($output->isVerbose()) { + /** @var UuidUpdateReport $report */ + foreach ($this->reports[UuidUpdateReport::UPDATED] as $report) { + $output->writeln(sprintf(' %s had their old UUID %s updated to %s', $report->id, $report->oldUuid, $report->newUuid)); + } + $output->writeln(''); + } + } + + if (count($this->reports[UuidUpdateReport::UNMAPPED]) > 0) { + $output->writeln(sprintf('%d provided IDs were not mapped. These were:', count($this->reports[UuidUpdateReport::UNMAPPED]))); + /** @var UuidUpdateReport $report */ + foreach ($this->reports[UuidUpdateReport::UNMAPPED] as $report) { + if (!empty($report->id)) { + $output->writeln(sprintf(' %s: %s', + $report->isUser ? 'User' : 'Group', $report->id)); + } elseif (!empty($report->dn)) { + $output->writeln(sprintf(' DN: %s', $report->dn)); + } + } + $output->writeln(''); + } + + if (count($this->reports[UuidUpdateReport::UNKNOWN]) > 0) { + $output->writeln(sprintf('%d provided IDs were unknown on LDAP.', count($this->reports[UuidUpdateReport::UNKNOWN]))); + if ($output->isVerbose()) { + /** @var UuidUpdateReport $report */ + foreach ($this->reports[UuidUpdateReport::UNKNOWN] as $report) { + $output->writeln(sprintf(' %s: %s',$report->isUser ? 'User' : 'Group', $report->id)); + } + $output->writeln(PHP_EOL . 'Old users can be removed along with their data per occ user:delete.' . PHP_EOL); + } + } + + if (count($this->reports[UuidUpdateReport::UNREADABLE]) > 0) { + $output->writeln(sprintf('For %d records, the UUID could not be read. Double-check your configuration.', count($this->reports[UuidUpdateReport::UNREADABLE]))); + if ($output->isVerbose()) { + /** @var UuidUpdateReport $report */ + foreach ($this->reports[UuidUpdateReport::UNREADABLE] as $report) { + $output->writeln(sprintf(' %s: %s',$report->isUser ? 'User' : 'Group', $report->id)); + } + } + } + + if (count($this->reports[UuidUpdateReport::UNWRITABLE]) > 0) { + $output->writeln(sprintf('For %d records, the UUID could not be saved to database. Double-check your configuration.', count($this->reports[UuidUpdateReport::UNWRITABLE]))); + if ($output->isVerbose()) { + /** @var UuidUpdateReport $report */ + foreach ($this->reports[UuidUpdateReport::UNWRITABLE] as $report) { + $output->writeln(sprintf(' %s: %s',$report->isUser ? 'User' : 'Group', $report->id)); + } + } + } + } + + protected function handleUpdates(InputInterface $input): \Generator { + if ($input->getOption('all')) { + foreach ($this->handleMappingBasedUpdates(false) as $_) { + yield; + } + } elseif ($input->getOption('userId') + || $input->getOption('groupId') + || $input->getOption('dn') + ) { + foreach ($this->handleUpdatesByUserId($input->getOption('userId')) as $_) { + yield; + } + foreach ($this->handleUpdatesByGroupId($input->getOption('groupId')) as $_) { + yield; + } + foreach ($this->handleUpdatesByDN($input->getOption('dn')) as $_) { + yield; + } + } else { + foreach ($this->handleMappingBasedUpdates(true) as $_) { + yield; + } + } + } + + protected function handleUpdatesByUserId(array $userIds): \Generator { + foreach ($this->handleUpdatesByEntryId($userIds, $this->userMapping) as $_) { + yield; + } + } + + protected function handleUpdatesByGroupId(array $groupIds): \Generator { + foreach ($this->handleUpdatesByEntryId($groupIds, $this->groupMapping) as $_) { + yield; + } + } + + protected function handleUpdatesByDN(array $dns): \Generator { + $userList = $groupList = []; + while ($dn = array_pop($dns)) { + $uuid = $this->userMapping->getUUIDByDN($dn); + if ($uuid) { + $id = $this->userMapping->getNameByDN($dn); + $userList[] = ['name' => $id, 'uuid' => $uuid]; + continue; + } + $uuid = $this->groupMapping->getUUIDByDN($dn); + if ($uuid) { + $id = $this->groupMapping->getNameByDN($dn); + $groupList[] = ['name' => $id, 'uuid' => $uuid]; + continue; + } + $this->reports[UuidUpdateReport::UNMAPPED][] = new UuidUpdateReport('', $dn, true, UuidUpdateReport::UNMAPPED); + yield; + } + foreach ($this->handleUpdatesByList($this->userMapping, $userList) as $_) { + yield; + } + foreach ($this->handleUpdatesByList($this->groupMapping, $groupList) as $_) { + yield; + } + } + + protected function handleUpdatesByEntryId(array $ids, AbstractMapping $mapping): \Generator { + $isUser = $mapping instanceof UserMapping; + $list = []; + while ($id = array_pop($ids)) { + if (!$dn = $mapping->getDNByName($id)) { + $this->reports[UuidUpdateReport::UNMAPPED][] = new UuidUpdateReport($id, '', $isUser, UuidUpdateReport::UNMAPPED); + yield; + continue; + } + // Since we know it was mapped the UUID is populated + $uuid = $mapping->getUUIDByDN($dn); + $list[] = ['name' => $id, 'uuid' => $uuid]; + } + foreach ($this->handleUpdatesByList($mapping, $list) as $_) { + yield; + } + } + + protected function handleMappingBasedUpdates(bool $invalidatedOnly): \Generator { + $limit = 1000; + /** @var AbstractMapping $mapping*/ + foreach ([$this->userMapping, $this->groupMapping] as $mapping) { + $offset = 0; + do { + $list = $mapping->getList($offset, $limit, $invalidatedOnly); + $offset += $limit; + + foreach ($this->handleUpdatesByList($mapping, $list) as $tick) { + yield; // null, for it only advances progress counter + } + } while (count($list) === $limit); + } + } + + protected function handleUpdatesByList(AbstractMapping $mapping, array $list): \Generator { + if ($mapping instanceof UserMapping) { + $isUser = true; + $backendProxy = $this->userProxy; + } else { + $isUser = false; + $backendProxy = $this->groupProxy; + } + + foreach ($list as $row) { + $access = $backendProxy->getLDAPAccess($row['name']); + if ($access instanceof Access + && $dn = $mapping->getDNByName($row['name'])) { + if ($uuid = $access->getUUID($dn, $isUser)) { + if ($uuid !== $row['uuid']) { + if ($this->dryRun || $mapping->setUUIDbyDN($uuid, $dn)) { + $this->reports[UuidUpdateReport::UPDATED][] + = new UuidUpdateReport($row['name'], $dn, $isUser, UuidUpdateReport::UPDATED, $row['uuid'], $uuid); + } else { + $this->reports[UuidUpdateReport::UNWRITABLE][] + = new UuidUpdateReport($row['name'], $dn, $isUser, UuidUpdateReport::UNWRITABLE, $row['uuid'], $uuid); + } + $this->logger->info('UUID of {id} was updated from {from} to {to}', + [ + 'appid' => 'user_ldap', + 'id' => $row['name'], + 'from' => $row['uuid'], + 'to' => $uuid, + ] + ); + } + } else { + $this->reports[UuidUpdateReport::UNREADABLE][] = new UuidUpdateReport($row['name'], $dn, $isUser, UuidUpdateReport::UNREADABLE); + } + } else { + $this->reports[UuidUpdateReport::UNKNOWN][] = new UuidUpdateReport($row['name'], '', $isUser, UuidUpdateReport::UNKNOWN); + } + yield; // null, for it only advances progress counter + } + } + + protected function estimateNumberOfUpdates(InputInterface $input): int { + if ($input->getOption('all')) { + return $this->userMapping->count() + $this->groupMapping->count(); + } elseif ($input->getOption('userId') + || $input->getOption('groupId') + || $input->getOption('dn') + ) { + return count($input->getOption('userId')) + + count($input->getOption('groupId')) + + count($input->getOption('dn')); + } else { + return $this->userMapping->countInvalidated() + $this->groupMapping->countInvalidated(); + } + } +} diff --git a/apps/user_ldap/lib/Jobs/CleanUp.php b/apps/user_ldap/lib/Jobs/CleanUp.php index b516e08b9e195..a47968962f3b2 100644 --- a/apps/user_ldap/lib/Jobs/CleanUp.php +++ b/apps/user_ldap/lib/Jobs/CleanUp.php @@ -45,7 +45,7 @@ * @package OCA\User_LDAP\Jobs; */ class CleanUp extends TimedJob { - /** @var int $limit amount of users that should be checked per run */ + /** @var ?int $limit amount of users that should be checked per run */ protected $limit; /** @var int $defaultIntervalMin default interval in minutes */ @@ -79,7 +79,7 @@ public function __construct() { * assigns the instances passed to run() to the class properties * @param array $arguments */ - public function setArguments($arguments) { + public function setArguments($arguments): void { //Dependency Injection is not possible, because the constructor will //only get values that are serialized to JSON. I.e. whatever we would //pass in app.php we do add here, except something else is passed e.g. @@ -134,19 +134,13 @@ public function setArguments($arguments) { * makes the background job do its work * @param array $argument */ - public function run($argument) { + public function run($argument): void { $this->setArguments($argument); if (!$this->isCleanUpAllowed()) { return; } $users = $this->mapping->getList($this->getOffset(), $this->getChunkSize()); - if (!is_array($users)) { - //something wrong? Let's start from the beginning next time and - //abort - $this->setOffset(true); - return; - } $resetOffset = $this->isOffsetResetNecessary(count($users)); $this->checkUsers($users); $this->setOffset($resetOffset); @@ -154,18 +148,15 @@ public function run($argument) { /** * checks whether next run should start at 0 again - * @param int $resultCount - * @return bool */ - public function isOffsetResetNecessary($resultCount) { + public function isOffsetResetNecessary(int $resultCount): bool { return $resultCount < $this->getChunkSize(); } /** * checks whether cleaning up LDAP users is allowed - * @return bool */ - public function isCleanUpAllowed() { + public function isCleanUpAllowed(): bool { try { if ($this->ldapHelper->haveDisabledConfigurations()) { return false; @@ -179,9 +170,8 @@ public function isCleanUpAllowed() { /** * checks whether clean up is enabled by configuration - * @return bool */ - private function isCleanUpEnabled() { + private function isCleanUpEnabled(): bool { return (bool)$this->ocConfig->getSystemValue( 'ldapUserCleanupInterval', (string)$this->defaultIntervalMin); } @@ -190,7 +180,7 @@ private function isCleanUpEnabled() { * checks users whether they are still existing * @param array $users result from getMappedUsers() */ - private function checkUsers(array $users) { + private function checkUsers(array $users): void { foreach ($users as $user) { $this->checkUser($user); } @@ -200,7 +190,7 @@ private function checkUsers(array $users) { * checks whether a user is still existing in LDAP * @param string[] $user */ - private function checkUser(array $user) { + private function checkUser(array $user): void { if ($this->userBackend->userExistsOnLDAP($user['name'])) { //still available, all good @@ -212,29 +202,27 @@ private function checkUser(array $user) { /** * gets the offset to fetch users from the mappings table - * @return int */ - private function getOffset() { - return (int)$this->ocConfig->getAppValue('user_ldap', 'cleanUpJobOffset', 0); + private function getOffset(): int { + return (int)$this->ocConfig->getAppValue('user_ldap', 'cleanUpJobOffset', '0'); } /** * sets the new offset for the next run * @param bool $reset whether the offset should be set to 0 */ - public function setOffset($reset = false) { + public function setOffset(bool $reset = false): void { $newOffset = $reset ? 0 : $this->getOffset() + $this->getChunkSize(); - $this->ocConfig->setAppValue('user_ldap', 'cleanUpJobOffset', $newOffset); + $this->ocConfig->setAppValue('user_ldap', 'cleanUpJobOffset', (string)$newOffset); } /** * returns the chunk size (limit in DB speak) - * @return int */ - public function getChunkSize() { + public function getChunkSize(): int { if ($this->limit === null) { - $this->limit = (int)$this->ocConfig->getAppValue('user_ldap', 'cleanUpJobChunkSize', 50); + $this->limit = (int)$this->ocConfig->getAppValue('user_ldap', 'cleanUpJobChunkSize', '50'); } return $this->limit; } diff --git a/apps/user_ldap/lib/Mapping/AbstractMapping.php b/apps/user_ldap/lib/Mapping/AbstractMapping.php index 29c962b336e19..05da7a8f2fff8 100644 --- a/apps/user_ldap/lib/Mapping/AbstractMapping.php +++ b/apps/user_ldap/lib/Mapping/AbstractMapping.php @@ -318,26 +318,24 @@ public function getUUIDByDN($dn) { return $this->getXbyY('directory_uuid', 'ldap_dn_hash', $this->getDNHash($dn)); } - /** - * gets a piece of the mapping list - * - * @param int $offset - * @param int $limit - * @return array - */ - public function getList($offset = null, $limit = null) { - $query = $this->dbc->prepare(' - SELECT - `ldap_dn` AS `dn`, - `owncloud_name` AS `name`, - `directory_uuid` AS `uuid` - FROM `' . $this->getTableName() . '`', - $limit, - $offset - ); - - $query->execute(); - return $query->fetchAll(); + public function getList(int $offset = 0, int $limit = null, bool $invalidatedOnly = false): array { + $select = $this->dbc->getQueryBuilder(); + $select->selectAlias('ldap_dn', 'dn') + ->selectAlias('owncloud_name', 'name') + ->selectAlias('directory_uuid', 'uuid') + ->from($this->getTableName()) + ->setMaxResults($limit) + ->setFirstResult($offset); + + if ($invalidatedOnly) { + $select->where($select->expr()->like('directory_uuid', $select->createNamedParameter('invalidated_%'))); + } + + $result = $select->execute(); + $entries = $result->fetchAll(); + $result->closeCursor(); + + return $entries; } /** @@ -436,13 +434,24 @@ public function clearCb(callable $preCallback, callable $postCallback): bool { * * @return int */ - public function count() { - $qb = $this->dbc->getQueryBuilder(); - $query = $qb->select($qb->func()->count('ldap_dn_hash')) + public function count(): int { + $query = $this->dbc->getQueryBuilder(); + $query->select($query->func()->count('ldap_dn_hash')) ->from($this->getTableName()); $res = $query->execute(); $count = $res->fetchColumn(); $res->closeCursor(); return (int)$count; } + + public function countInvalidated(): int { + $query = $this->dbc->getQueryBuilder(); + $query->select($query->func()->count('ldap_dn_hash')) + ->from($this->getTableName()) + ->where($query->expr()->like('directory_uuid', $query->createNamedParameter('invalidated_%'))); + $res = $query->execute(); + $count = $res->fetchColumn(); + $res->closeCursor(); + return (int)$count; + } } diff --git a/apps/user_ldap/lib/Migration/Version1130Date20211102154716.php b/apps/user_ldap/lib/Migration/Version1130Date20211102154716.php index 0c1dcee0a3506..7566098832efa 100644 --- a/apps/user_ldap/lib/Migration/Version1130Date20211102154716.php +++ b/apps/user_ldap/lib/Migration/Version1130Date20211102154716.php @@ -27,8 +27,11 @@ namespace OCA\User_LDAP\Migration; use Closure; +use Doctrine\DBAL\DBALException; +use Doctrine\DBAL\Driver\Statement; +use Doctrine\DBAL\Exception\ConstraintViolationException; use Doctrine\DBAL\Types\Types; -use OCP\DB\Exception; +use Generator; use OCP\DB\ISchemaWrapper; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; @@ -52,6 +55,23 @@ public function getName() { return 'Adjust LDAP user and group ldap_dn column lengths and add ldap_dn_hash columns'; } + public function preSchemaChange(IOutput $output, \Closure $schemaClosure, array $options) { + foreach (['ldap_user_mapping', 'ldap_group_mapping'] as $tableName) { + $this->processDuplicateUUIDs($tableName); + } + + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + if ($schema->hasTable('ldap_group_mapping_backup')) { + // Previous upgrades of a broken release might have left an incomplete + // ldap_group_mapping_backup table. No need to recreate, but it + // should be empty. + // TRUNCATE is not available from Query Builder, but faster than DELETE FROM. + $sql = $this->dbc->getDatabasePlatform()->getTruncateTableSQL('ldap_group_mapping_backup', false); + $this->dbc->executeUpdate($sql); + } + } + /** * @param IOutput $output * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` @@ -91,7 +111,7 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt $table->addUniqueIndex(['directory_uuid'], 'ldap_user_directory_uuid'); $changeSchema = true; } - } else { + } elseif (!$schema->hasTable('ldap_group_mapping_backup')) { // We need to copy the table twice to be able to change primary key, prepare the backup table $table2 = $schema->createTable('ldap_group_mapping_backup'); $table2->addColumn('ldap_dn', Types::STRING, [ @@ -142,7 +162,7 @@ protected function handleDNHashes(string $table): void { $update->setParameter('dn_hash', $dnHash); try { $update->execute(); - } catch (Exception $e) { + } catch (DBALException $e) { $this->logger->error('Failed to add hash "{dnHash}" ("{name}" of {table})', [ 'app' => 'user_ldap', @@ -172,4 +192,89 @@ protected function getUpdateQuery(string $table): IQueryBuilder { ->where($qb->expr()->eq('owncloud_name', $qb->createParameter('name'))); return $qb; } + + /** + * @throws DBALException + */ + protected function processDuplicateUUIDs(string $table): void { + $uuids = $this->getDuplicatedUuids($table); + $idsWithUuidToInvalidate = []; + foreach ($uuids as $uuid) { + array_push($idsWithUuidToInvalidate, ...$this->getNextcloudIdsByUuid($table, $uuid)); + } + $this->invalidateUuids($table, $idsWithUuidToInvalidate); + } + + /** + * @throws DBALException + */ + protected function invalidateUuids(string $table, array $idList): void { + $update = $this->dbc->getQueryBuilder(); + $update->update($table) + ->set('directory_uuid', $update->createParameter('invalidatedUuid')) + ->where($update->expr()->eq('owncloud_name', $update->createParameter('nextcloudId'))); + + while ($nextcloudId = array_shift($idList)) { + $update->setParameter('nextcloudId', $nextcloudId); + $update->setParameter('invalidatedUuid', 'invalidated_' . \bin2hex(\random_bytes(6))); + try { + $update->execute(); + $this->logger->warning( + 'LDAP user or group with ID {nid} has a duplicated UUID value which therefore was invalidated. You may double-check your LDAP configuration and trigger an update of the UUID.', + [ + 'app' => 'user_ldap', + 'nid' => $nextcloudId, + ] + ); + } catch (DBALException $e) { + // Catch possible, but unlikely duplications if new invalidated errors. + // There is the theoretical chance of an infinity loop is, when + // the constraint violation has a different background. I cannot + // think of one at the moment. + if (!$e instanceof ConstraintViolationException) { + throw $e; + } + $idList[] = $nextcloudId; + } + } + } + + /** + * @throws DBALException + * @return array + */ + protected function getNextcloudIdsByUuid(string $table, string $uuid): array { + $select = $this->dbc->getQueryBuilder(); + $select->select('owncloud_name') + ->from($table) + ->where($select->expr()->eq('directory_uuid', $select->createNamedParameter($uuid))); + + /** @var Statement $result */ + $result = $select->execute(); + $idList = []; + while ($id = $result->fetchColumn()) { + $idList[] = $id; + } + $result->closeCursor(); + return $idList; + } + + /** + * @return Generator + * @throws DBALException + */ + protected function getDuplicatedUuids(string $table): Generator { + $select = $this->dbc->getQueryBuilder(); + $select->select('directory_uuid') + ->from($table) + ->groupBy('directory_uuid') + ->having($select->expr()->gt($select->func()->count('owncloud_name'), $select->createNamedParameter(1))); + + /** @var Statement $result */ + $result = $select->execute(); + while ($uuid = $result->fetchColumn()) { + yield $uuid; + } + $result->closeCursor(); + } } diff --git a/apps/user_ldap/tests/Mapping/AbstractMappingTest.php b/apps/user_ldap/tests/Mapping/AbstractMappingTest.php index 35259345f2552..e4c37aff7cd29 100644 --- a/apps/user_ldap/tests/Mapping/AbstractMappingTest.php +++ b/apps/user_ldap/tests/Mapping/AbstractMappingTest.php @@ -274,7 +274,7 @@ public function testList() { $this->assertSame(count($data) - 1, count($results)); // get first 2 entries by limit, but not offset - $results = $mapper->getList(null, 2); + $results = $mapper->getList(0, 2); $this->assertSame(2, count($results)); // get 2nd entry by specifying both offset and limit diff --git a/core/js/setupchecks.js b/core/js/setupchecks.js index b379e81e5e60b..a4cd20835f5d0 100644 --- a/core/js/setupchecks.js +++ b/core/js/setupchecks.js @@ -507,6 +507,7 @@ OC.SetupChecks.addGenericSetupCheck(data, 'OCA\\Settings\\SetupChecks\\PhpOutputBuffering', messages) OC.SetupChecks.addGenericSetupCheck(data, 'OCA\\Settings\\SetupChecks\\LegacySSEKeyFormat', messages) OC.SetupChecks.addGenericSetupCheck(data, 'OCA\\Settings\\SetupChecks\\SupportedDatabase', messages) + OC.SetupChecks.addGenericSetupCheck(data, 'OCA\\Settings\\SetupChecks\\LdapInvalidUuids', messages) } else { messages.push({