diff --git a/appinfo/info.xml b/appinfo/info.xml index 9df4fb24..bd765481 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -45,6 +45,7 @@ While theoretically any other authentication provider implementing either one of OCA\User_SAML\Migration\CleanupRemovedConfig + OCA\User_SAML\Migration\TransferGroupMembers @@ -53,6 +54,7 @@ While theoretically any other authentication provider implementing either one of OCA\User_SAML\Command\ConfigGet OCA\User_SAML\Command\ConfigSet OCA\User_SAML\Command\GetMetadata + OCA\User_SAML\Command\GroupMigrationCopyIncomplete OCA\User_SAML\Settings\Admin diff --git a/lib/Command/ConfigDelete.php b/lib/Command/ConfigDelete.php index 0d5e04d3..eba270cc 100644 --- a/lib/Command/ConfigDelete.php +++ b/lib/Command/ConfigDelete.php @@ -44,7 +44,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->samlSettings->delete($pId); $output->writeln('Provider deleted.'); } catch (Exception $e) { - $output->writeln('Provider with id: ' . $providerId . ' does not exist.'); + $output->writeln('Provider with id: ' . $pId . ' does not exist.'); return 1; } return 0; diff --git a/lib/Command/GroupMigrationCopyIncomplete.php b/lib/Command/GroupMigrationCopyIncomplete.php new file mode 100644 index 00000000..7d746245 --- /dev/null +++ b/lib/Command/GroupMigrationCopyIncomplete.php @@ -0,0 +1,93 @@ +setName('saml:group-migration:copy-incomplete-members'); + $this->setDescription('Transfers remaining group members from old local to current SAML groups'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $groupsToTreat = $this->groupMigration->findGroupsWithLocalMembers(); + if (empty($groupsToTreat)) { + if ($output->isVerbose()) { + $output->writeln('No pending group member transfer'); + } + return 0; + } + + if (!$this->doMemberTransfer($groupsToTreat, $output)) { + if (!$output->isQuiet()) { + $output->writeln('Not all group members could be transferred completely. Rerun this command or check the Nextcloud log.'); + } + return 1; + } + + if (!$output->isQuiet()) { + $output->writeln('All group members could be transferred completely.'); + } + return 0; + } + + /** + * @param string[]|array $groups + * @param OutputInterface $output + * @return bool + */ + protected function doMemberTransfer(array $groups, OutputInterface $output): bool { + $errorOccurred = false; + for ($i = 0; $i < 2; $i++) { + $retry = []; + foreach ($groups as $gid) { + try { + $isComplete = $this->groupMigration->migrateGroupUsers($gid); + if (!$isComplete) { + $retry[] = $gid; + } else { + $this->groupMigration->cleanUpOldGroupUsers($gid); + if ($output->isVerbose()) { + $output->writeln(sprintf('Members transferred successfully for group %s', $gid)); + } + } + } catch (Throwable $e) { + $errorOccurred = true; + if (!$output->isQuiet()) { + $output->writeln(sprintf('Failed to transfer users from group %s: %s', $gid, $e->getMessage())); + } + $this->logger->warning('Error while transferring group members of {gid}', ['gid' => $gid, 'exception' => $e]); + } + } + if (empty($retry)) { + return true; + } + /** @var string[]|array $groups */ + $groups = $retry; + } + if (!empty($groups) && !$output->isQuiet()) { + $output->writeln(sprintf( + 'Members not or incompletely transferred for groups: %s', + implode(', ', $groups) + )); + } + return empty($groups) && !$errorOccurred; + } +} diff --git a/lib/GroupManager.php b/lib/GroupManager.php index 989c1bb4..63948fa1 100644 --- a/lib/GroupManager.php +++ b/lib/GroupManager.php @@ -23,6 +23,7 @@ class GroupManager { public const LOCAL_GROUPS_CHECK_FOR_MIGRATION = 'localGroupsCheckForMigration'; + public const STATE_MIGRATION_PHASE_EXPIRED = 'EXPIRED'; /** * @var IDBConnection $db @@ -68,8 +69,6 @@ public function __construct( */ private function getGroupsToRemove(array $samlGroupNames, array $assignedGroups): array { $groupsToRemove = []; - // FIXME: Seems unused - $this->config->getAppValue('user_saml', self::LOCAL_GROUPS_CHECK_FOR_MIGRATION, ''); foreach ($assignedGroups as $group) { // if group is not supplied by SAML and group has SAML backend if (!in_array($group->getGID(), $samlGroupNames) && $this->hasSamlBackend($group)) { @@ -201,7 +200,7 @@ protected function findGroup(string $gid): IGroup { $strictBackendCheck = $migrationAllowListRaw === ''; $migrationAllowList = null; - if ($migrationAllowListRaw !== '') { + if ($migrationAllowListRaw !== '' || $migrationAllowListRaw !== self::STATE_MIGRATION_PHASE_EXPIRED) { /** @var array{dropAfter: int, groups: string[]} $migrationAllowList */ $migrationAllowList = \json_decode($migrationAllowListRaw, true); } @@ -238,13 +237,8 @@ protected function hasSamlBackend(IGroup $group): bool { } protected function evaluateGroupMigrations(array $groups): void { - $candidateInfo = $this->config->getAppValue('user_saml', self::LOCAL_GROUPS_CHECK_FOR_MIGRATION, ''); - if ($candidateInfo === '') { - return; - } - $candidateInfo = \json_decode($candidateInfo, true); - if (!isset($candidateInfo['dropAfter']) || $candidateInfo['dropAfter'] < time()) { - $this->config->deleteAppValue('user_saml', self::LOCAL_GROUPS_CHECK_FOR_MIGRATION); + $candidateInfo = $this->getCandidateInfoIfValid(); + if ($candidateInfo === null) { return; } @@ -252,17 +246,29 @@ protected function evaluateGroupMigrations(array $groups): void { } protected function isGroupInTransitionList(string $groupId): bool { - $candidateInfo = $this->config->getAppValue('user_saml', self::LOCAL_GROUPS_CHECK_FOR_MIGRATION, ''); - if ($candidateInfo === '') { + $candidateInfo = $this->getCandidateInfoIfValid(); + if (!$candidateInfo) { return false; } + return in_array($groupId, $candidateInfo['groups'], true); + } + + /** + * @return array{dropAfter: int, groups: string[]}|null + */ + public function getCandidateInfoIfValid(): ?array { + $candidateInfo = $this->config->getAppValue('user_saml', self::LOCAL_GROUPS_CHECK_FOR_MIGRATION, ''); + if ($candidateInfo === '' || $candidateInfo === self::STATE_MIGRATION_PHASE_EXPIRED) { + return null; + } + /** @var array{dropAfter: int, groups: string[]} $candidateInfo */ $candidateInfo = \json_decode($candidateInfo, true); if (!isset($candidateInfo['dropAfter']) || $candidateInfo['dropAfter'] < time()) { - $this->config->deleteAppValue('user_saml', self::LOCAL_GROUPS_CHECK_FOR_MIGRATION); - return false; + $this->config->setAppValue('user_saml', self::LOCAL_GROUPS_CHECK_FOR_MIGRATION, self::STATE_MIGRATION_PHASE_EXPIRED); + return null; } - return in_array($groupId, $candidateInfo['groups']); + return $candidateInfo; } protected function hasGroupForeignMembers(IGroup $group): bool { diff --git a/lib/Jobs/MigrateGroups.php b/lib/Jobs/MigrateGroups.php index d5e02338..8a41a65f 100644 --- a/lib/Jobs/MigrateGroups.php +++ b/lib/Jobs/MigrateGroups.php @@ -10,12 +10,16 @@ use OCA\User_SAML\GroupBackend; use OCA\User_SAML\GroupManager; +use OCA\User_SAML\Service\GroupMigration; +use OCP\AppFramework\Db\TTransactional; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\QueuedJob; +use OCP\DB\Exception; use OCP\IConfig; use OCP\IDBConnection; use OCP\IGroupManager; use Psr\Log\LoggerInterface; +use Throwable; /** * Class MigrateGroups @@ -24,6 +28,9 @@ * @todo: remove this, when dropping Nextcloud 29 support */ class MigrateGroups extends QueuedJob { + use TTransactional; + + protected const BATCH_SIZE = 1000; /** @var IConfig */ private $config; @@ -37,12 +44,14 @@ class MigrateGroups extends QueuedJob { private $logger; public function __construct( + protected GroupMigration $groupMigration, + protected GroupManager $ownGroupManager, IConfig $config, IGroupManager $groupManager, IDBConnection $dbc, GroupBackend $ownGroupBackend, LoggerInterface $logger, - ITimeFactory $timeFactory + ITimeFactory $timeFactory, ) { parent::__construct($timeFactory); $this->config = $config; @@ -63,16 +72,16 @@ protected function run($argument) { } } - protected function updateCandidatePool($migrateGroups) { + protected function updateCandidatePool(array $migratedGroups): void { $candidateInfo = $this->config->getAppValue('user_saml', GroupManager::LOCAL_GROUPS_CHECK_FOR_MIGRATION, ''); - if ($candidateInfo === null || $candidateInfo === '') { + if ($candidateInfo === '' || $candidateInfo === GroupManager::STATE_MIGRATION_PHASE_EXPIRED) { return; } $candidateInfo = \json_decode($candidateInfo, true); if (!isset($candidateInfo['dropAfter']) || !isset($candidateInfo['groups'])) { return; } - $candidateInfo['groups'] = array_diff($candidateInfo['groups'], $migrateGroups); + $candidateInfo['groups'] = array_diff($candidateInfo['groups'], $migratedGroups); $this->config->setAppValue( 'user_saml', GroupManager::LOCAL_GROUPS_CHECK_FOR_MIGRATION, @@ -87,7 +96,11 @@ protected function migrateGroups(array $toMigrate): array { } protected function migrateGroup(string $gid): bool { + $isMigrated = false; + $allUsersInserted = false; try { + $allUsersInserted = $this->groupMigration->migrateGroupUsers($gid); + $this->dbc->beginTransaction(); $qb = $this->dbc->getQueryBuilder(); @@ -97,20 +110,30 @@ protected function migrateGroup(string $gid): bool { if ($affected === 0) { throw new \RuntimeException('Could not delete group from local backend'); } - if (!$this->ownGroupBackend->createGroup($gid)) { throw new \RuntimeException('Could not create group in SAML backend'); } $this->dbc->commit(); - - return true; - } catch (\Exception $e) { + $isMigrated = true; + } catch (Throwable $e) { $this->dbc->rollBack(); $this->logger->warning($e->getMessage(), ['app' => 'user_saml', 'exception' => $e]); } - return false; + if ($allUsersInserted && $isMigrated) { + try { + $this->groupMigration->cleanUpOldGroupUsers($gid); + } catch (Exception $e) { + $this->logger->warning('Error while cleaning up group members in (oc_)group_user of group (gid) {gid}', [ + 'app' => 'user_saml', + 'gid' => $gid, + 'exception' => $e, + ]); + } + } + + return $isMigrated; } protected function getGroupsToMigrate(array $samlGroups, array $pool): array { @@ -140,14 +163,9 @@ protected function getGroupsToMigrate(array $samlGroups, array $pool): array { } protected function getMigratableGroups(): array { - $candidateInfo = $this->config->getAppValue('user_saml', GroupManager::LOCAL_GROUPS_CHECK_FOR_MIGRATION, ''); - if ($candidateInfo === null || $candidateInfo === '') { - throw new \RuntimeException('No migration of groups to SAML backend anymore'); - } - $candidateInfo = \json_decode($candidateInfo, true); - if (!isset($candidateInfo['dropAfter']) || !isset($candidateInfo['groups']) || $candidateInfo['dropAfter'] < time()) { - $this->config->deleteAppValue('user_saml', GroupManager::LOCAL_GROUPS_CHECK_FOR_MIGRATION); - throw new \RuntimeException('Period for migration groups is over'); + $candidateInfo = $this->ownGroupManager->getCandidateInfoIfValid(); + if ($candidateInfo === null) { + throw new \RuntimeException('No migration tasks of groups to SAML backend'); } return $candidateInfo['groups']; diff --git a/lib/Migration/RememberLocalGroupsForPotentialMigrations.php b/lib/Migration/RememberLocalGroupsForPotentialMigrations.php index cafbfd46..09c73562 100644 --- a/lib/Migration/RememberLocalGroupsForPotentialMigrations.php +++ b/lib/Migration/RememberLocalGroupsForPotentialMigrations.php @@ -44,6 +44,11 @@ public function getName(): string { * @since 9.1.0 */ public function run(IOutput $output) { + $candidateInfo = $this->config->getAppValue('user_saml', GroupManager::LOCAL_GROUPS_CHECK_FOR_MIGRATION, ''); + if ($candidateInfo !== '') { + return; + } + try { $backend = $this->findBackend(); $groupIds = $this->findGroupIds($backend); diff --git a/lib/Migration/TransferGroupMembers.php b/lib/Migration/TransferGroupMembers.php new file mode 100644 index 00000000..68e13424 --- /dev/null +++ b/lib/Migration/TransferGroupMembers.php @@ -0,0 +1,52 @@ +groupMigration->findGroupsWithLocalMembers(); + if (empty($groupsToTreat)) { + return; + } + $hasError = false; + $output->startProgress(count($groupsToTreat)); + foreach ($groupsToTreat as $gid) { + try { + if ($this->groupMigration->migrateGroupUsers($gid)) { + $this->groupMigration->cleanUpOldGroupUsers($gid); + } + } catch (Throwable $e) { + $hasError = true; + $this->logger->warning('Error while transferring group members of {gid}', ['gid' => $gid, 'exception' => $e]); + } finally { + $output->advance(); + } + } + $output->finishProgress(); + if ($hasError) { + $output->warning('There were errors while transferring group members to SAML groups. You may try later `occ saml:group-migration:copy-incomplete-members` later and check your nextcloud.log.'); + } + } +} diff --git a/lib/Service/GroupMigration.php b/lib/Service/GroupMigration.php new file mode 100644 index 00000000..ce3b7a26 --- /dev/null +++ b/lib/Service/GroupMigration.php @@ -0,0 +1,101 @@ +dbc->getQueryBuilder(); + $qb->selectDistinct('gid') + ->from('group_user') + ->where($qb->expr()->in('gid', $qb->createParameter('gidList'))); + + $allOwnedGroups = $this->ownGroupBackend->getGroups(); + foreach (array_chunk($allOwnedGroups, self::CHUNK_SIZE) as $groupsChunk) { + $qb->setParameter('gidList', $groupsChunk, IQueryBuilder::PARAM_STR_ARRAY); + $result = $qb->executeQuery(); + while ($gid = $result->fetchOne()) { + $foundGroups[] = $gid; + } + $result->closeCursor(); + } + + return $foundGroups; + } + + /** + * @returns bool true when all users were migrated, when they were only partly migrated + * @throws Exception + * @throws Throwable + */ + public function migrateGroupUsers(string $gid): bool { + $originalGroup = $this->groupManager->get($gid); + $members = $originalGroup?->getUsers(); + + $areAllInserted = true; + foreach (array_chunk($members ?? [], (int)floor(self::CHUNK_SIZE / 2)) as $userBatch) { + $areAllInserted = ($this->atomic(function () use ($userBatch, $gid) { + /** @var IUser $user */ + foreach ($userBatch as $user) { + $this->dbc->insertIgnoreConflict( + GroupBackend::TABLE_MEMBERS, + [ + 'gid' => $gid, + 'uid' => $user->getUID(), + ] + ); + } + return true; + }, $this->dbc) === true) && $areAllInserted; + } + if (!$areAllInserted) { + $this->logger->warning('Partial migration of users from local group {gid} to SAML.', [ + 'app' => 'user_saml', + 'gid' => $gid, + ]); + } + return $areAllInserted; + } + + /** + * @throws Exception + */ + public function cleanUpOldGroupUsers(string $gid): void { + $cleanup = $this->dbc->getQueryBuilder(); + $cleanup->delete('group_user') + ->where($cleanup->expr()->eq('gid', $cleanup->createNamedParameter($gid))); + $cleanup->executeStatement(); + } + +} diff --git a/tests/integration/features/Shibboleth.feature b/tests/integration/features/Shibboleth.feature index 240e8282..5503a5b3 100644 --- a/tests/integration/features/Shibboleth.feature +++ b/tests/integration/features/Shibboleth.feature @@ -165,6 +165,139 @@ Feature: Shibboleth And Then the group backend of "Astrophysics" must not be "Database" And The user value "groups" should be "Astrophysics,SAML_Students" + Scenario: Migrating a local group to SAML backend and taking the assigned users as well + Given The setting "type" is set to "saml" + And The setting "general-uid_mapping" is set to "urn:oid:0.9.2342.19200300.100.1.1" + And The setting "idp-entityId" is set to "https://shibboleth-integration-nextcloud.localdomain/idp/shibboleth" + And The setting "idp-singleSignOnService.url" is set to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO" + And The setting "idp-singleLogoutService.url" is set to "https://localhost:4443/idp/profile/Logout" + And The setting "idp-x509cert" is set to "MIIDnTCCAoWgAwIBAgIUGPx9uPjCu7c172IUgV84Tm94pBcwDQYJKoZIhvcNAQEL BQAwNzE1MDMGA1UEAwwsc2hpYmJvbGV0aC1pbnRlZ3JhdGlvbi1uZXh0Y2xvdWQu bG9jYWxkb21haW4wHhcNMTcwMTA0MTAxMTI3WhcNMzcwMTA0MTAxMTI3WjA3MTUw MwYDVQQDDCxzaGliYm9sZXRoLWludGVncmF0aW9uLW5leHRjbG91ZC5sb2NhbGRv bWFpbjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKH+bPu45tk8/JRk XOxkyfbxocWZlY4mRumEUxscd3fn0oVzOrdWbHH7lCZV4bus4KxvJljc0Nm2K+Zr LoiRUUnf/LQ4LlehWVm5Kbc4kRgOXS0iGZN3SslAWPKyIg0tywg+TLOBPoS6EtST 1WuYg1JPMFxPfeFDWQ0dQYPlXIJWBFh6F2JMTb0FLECqA5l/ryYE13QisX5l+Mqo 6y3Dh7qIgaH0IJNobXoAcEWza7Kb2RnfhZRh9e0qjZIwBqTJUFM/6I86RYXn829s INUvYQQbez6VkGTdUQJ/GuXb/dD5sMQfOyK8hrRY5MozOmK32cz3JaAzSXpiSRS9 NxFwvicCAwEAAaOBoDCBnTAdBgNVHQ4EFgQUKn8+TV0WXSDeavvF0M8mWn1o8ukw fAYDVR0RBHUwc4Isc2hpYmJvbGV0aC1pbnRlZ3JhdGlvbi1uZXh0Y2xvdWQubG9j YWxkb21haW6GQ2h0dHBzOi8vc2hpYmJvbGV0aC1pbnRlZ3JhdGlvbi1uZXh0Y2xv dWQubG9jYWxkb21haW4vaWRwL3NoaWJib2xldGgwDQYJKoZIhvcNAQELBQADggEB ABI6uzoIeLZT9Az2KTlLxIc6jZ4MDmhaVja4ZuBxTXEb7BFLfeASEJmQm1wgIMOn pJId3Kh3njW+3tOBWKm7lj8JxVVpAu4yMFSoQGPaVUgYB1AVm+pmAyPLzfJ/XGhf esCU2F/b0eHWcaIb3x+BZFX089Cd/PBtP84MNXdo+TccibxC8N39sr45qJM/7SC7 TfDYU0L4q2WZHJr4S7+0F+F4KaxLx9NzCvN4h6XaoWofZWir2iHO4NzbrVQGC0ei QybS/neBfni4A2g1lyzCb6xFB58JBvNCn7AAnDJULOE7S5XWUKsDAQVQrxTNkUq7 pnhlCQqZDwUdgmIXd1KB1So=" + And The setting "security-authnRequestsSigned" is set to "1" + And The setting "security-wantAssertionsEncrypted" is set to "1" + And The setting "sp-x509cert" is set to "-----BEGIN CERTIFICATE-----MIIC+zCCAeOgAwIBAgIJAIgZuvWDBIrdMA0GCSqGSIb3DQEBBQUAMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0xNzAxMDQxMTM5MjFaFw0yNzAxMDIxMTM5MjFaMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAN3ESWaDH1JiJTy9yRJQV7kahPOxgBkIH2xwcYDL1k9deKNhSKLx7aGfxE244+HBcC6WLHKVUnOm0ld2qxQ4bMYiJXzZuqL67r07L5wxGAssv12lO92qohGmlHy3+VzRYUBmovu6upqOv3R2F8HBbo7Jc7Hvt7hOEJn/jPuFuF/fHit3mqU8l6IkrIZjpaW8T9fIWOXRq98U4+hkgWpqEZWsqlfE8BxAs9DeIMZab0GxO9stHLp+GYKx10uE4ezFcaDS8W+g2C8enCTt1HXGvcnj4o5zkC1lITGvcFTsiFqfIWyXeSufcxdc0W7HoG6J3ks0WJyK38sfFn0t2Ao6kX0CAwEAAaNQME4wHQYDVR0OBBYEFAoJzX6TVYAwC1GSPe6nObBG54zaMB8GA1UdIwQYMBaAFAoJzX6TVYAwC1GSPe6nObBG54zaMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAJia9R70uXdUZtgujUPjLas4+sVajzlBFmqhBqpLAo934vljf9HISsHrPtdBcbS0d0rucqXwabDf0MlR18ksnT/NYpsTwMbMx76CrXi4zYEEW5lISKEO65aIkzVTcqKWSuhjtSnRdB6iOLsFiKmNMWXaIKMR5T0+AbR9wdQgn08W+3EEeHGvafVQfE3STVsSgNb1ft7DvcSUnfPXGU7KzvmTpZa0Hfmc7uY4vpdEEhLAdRhgLReS7USZskov7ooiPSoD+JRFi2gM4klBxTemHdNUa9oFnHMXuYKOkLbkgFvHxyy+QlLq2ELQTga5e7I83ZyOfGctyf8Ul6vGw10vbQ=-----END CERTIFICATE-----" + And The setting "sp-privateKey" is set to "-----BEGIN RSA PRIVATE KEY-----MIIEpAIBAAKCAQEA3cRJZoMfUmIlPL3JElBXuRqE87GAGQgfbHBxgMvWT114o2FIovHtoZ/ETbjj4cFwLpYscpVSc6bSV3arFDhsxiIlfNm6ovruvTsvnDEYCyy/XaU73aqiEaaUfLf5XNFhQGai+7q6mo6/dHYXwcFujslzse+3uE4Qmf+M+4W4X98eK3eapTyXoiSshmOlpbxP18hY5dGr3xTj6GSBamoRlayqV8TwHECz0N4gxlpvQbE72y0cun4ZgrHXS4Th7MVxoNLxb6DYLx6cJO3Udca9yePijnOQLWUhMa9wVOyIWp8hbJd5K59zF1zRbsegboneSzRYnIrfyx8WfS3YCjqRfQIDAQABAoIBAQC5CQAdcqZ9vLpJNilBCJxJLCFmm+HAAREHD8MErg9A5UK1P4S1wJp/0qieGPi68wXBOTgY2xKSwMycgb04/+NyZidVRu388takOW/+KNBg8pMxdZ6/05GqnI0kivSbR3CXpYuz8hekwhpo9+fWmKjApsHL47ItK6WaeKmPbAFsq1YJGzfp/DXg7LIvh9GA3C1LWWGV7SuCGOyX/2Moi8xRa7qBtH4hDo/0NRhTx7zjYjlBgNEr330pJUopc3+AtHE40R+xMr2zkGvq9RsCZxYxD2VWbLwQW0yNjWmQ2OTuMgJJvk2+N73QLHcB+tea82ZTszsNzRS9DLtc6qbsKEPZAoGBAO78U3vEuRyY56f/1hpo0xuCDwOkWGzgBQWkjJl6dlyVz/zKkhXBHpEYImyt8XRN0W3iGZYpZ2hCFJGTcDp32R6UiEyGLz0Uc8R/tva/TiRVW1FdNczzSHcB24b9OMK4vE9JLs8mA8Rp8YBgtLr5DDuMfYt/a/rZJbg/HIfIN98nAoGBAO2OInCX93t2I6zzRPIqKtI6q6FYNp64VIQjvw9Y8l0x3IdJZRP9H5C8ZhCeYPsgEqTXcXa4j5hL4rQzoUtxfxflBUUH60bcnd4LGaTCMYLS14G011E3GZlIP0sJi5OjEhy8fq3zt6jVzS9V/lPHB8i+w1D7CbPrMpW7B3k32vC7AoGAX/HvdkYhZyjAAEOG6m1hK68IZhbp5TP+8CgCxm9S65K9wKh3A8LXibrdvzIKOP4w8WOPkCipOkMlTNibeu24vj01hztr5aK7Y40+oEtnjNCz67N3MQQO+LBHOSeaTRqrh01DPKjvZECAU2D/zfzEe3fIw2Nxr3DUYub7hkvMmosCgYAzxbVVypjiLGYsDDyrdmsstCKxoDMPNmcdAVljc+QmUXaZeXJw/8qAVb78wjeqo1vM1zNgR2rsKyW2VkZB1fN39q7GU6qAIBa7zLmDAduegmr7VrlSduq6UFeS9/qWa4TIBICrUqFlR2tXdKtgANF+e6y/mmaL8qdsoH1JetXZfwKBgQC1vscRpdAXivjOOZAh+mzJWzS4BUl4CTJLYYIuOEXikmN5g0EdV2fhUEdkewmyKnXHsd0x83167bYgpTDNs71jUxDHy5NXlg2qIjLkf09X9wr19gBzDApfWzfh3vUqttyMZuQMLVNepGCWM2vjlY9KGl5OvZqY6d+7yO0mLV9GmQ==-----END RSA PRIVATE KEY-----" + And The setting "security-wantAssertionsSigned" is set to "1" + And The setting "localGroupsCheckForMigration" is set to '{\"dropAfter\":9223372036854775807,\"groups\":[\"Astrophysics\",\"Students\"]}' + And the local group "Astrophysics" is created + # Initial login so the user is known and belonging to SAML, directly followed by a logout + And I send a GET request to "http://localhost:8080/index.php/login" + And I should be redirected to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO" + And I send a POST request to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO?execution=e1s1" with the following data + |j_username|j_password|_eventId_proceed| + |student1 |password | | + And The response should be a SAML redirect page that gets submitted + And I send a GET request with requesttoken to "http://localhost:8080/index.php/apps/user_saml/saml/sls" + And the cookies are cleared + # Have a second SAML users being known + And I send a GET request to "http://localhost:8080/index.php/login" + And I should be redirected to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO" + And I send a POST request to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO?execution=e1s1" with the following data + |j_username|j_password|_eventId_proceed| + |staff1 |password | | + And The response should be a SAML redirect page that gets submitted + And I send a GET request with requesttoken to "http://localhost:8080/index.php/apps/user_saml/saml/sls" + And the cookies are cleared + # The users are already known as members + And the user "student1" is added to the group "Astrophysics" + And the user "staff1" is added to the group "Astrophysics" + # Set the proper attributes + And The setting "saml-attribute-mapping-email_mapping" is set to "urn:oid:0.9.2342.19200300.100.1.3" + And The setting "saml-attribute-mapping-displayName_mapping" is set to "urn:oid:2.5.4.42 urn:oid:2.5.4.4" + And The setting "saml-attribute-mapping-group_mapping" is set to "groups" + # Now login in for good + When I send a GET request to "http://localhost:8080/index.php/login" + Then I should be redirected to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO" + And I send a POST request to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO?execution=e1s1" with the following data + |j_username|j_password|_eventId_proceed| + |student1 |password | | + And The response should be a SAML redirect page that gets submitted + And The user value "groups" should be "Astrophysics,SAML_Students" + And the group backend of "Astrophysics" should be "Database" + And Then the group backend of "Astrophysics" must not be "user_saml" + When I execute the background job for class "OCA\\User_SAML\\Jobs\\MigrateGroups" + Then the group backend of "Astrophysics" should be "user_saml" + And Then the group backend of "Astrophysics" must not be "Database" + And The user value "groups" should be "Astrophysics,SAML_Students" + And The group "Astrophysics" has exactly the members "student1, staff1" + + Scenario: Migrating a local group to SAML backend and taking the assigned users via occ command + Given The setting "type" is set to "saml" + And The setting "general-uid_mapping" is set to "urn:oid:0.9.2342.19200300.100.1.1" + And The setting "idp-entityId" is set to "https://shibboleth-integration-nextcloud.localdomain/idp/shibboleth" + And The setting "idp-singleSignOnService.url" is set to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO" + And The setting "idp-singleLogoutService.url" is set to "https://localhost:4443/idp/profile/Logout" + And The setting "idp-x509cert" is set to "MIIDnTCCAoWgAwIBAgIUGPx9uPjCu7c172IUgV84Tm94pBcwDQYJKoZIhvcNAQEL BQAwNzE1MDMGA1UEAwwsc2hpYmJvbGV0aC1pbnRlZ3JhdGlvbi1uZXh0Y2xvdWQu bG9jYWxkb21haW4wHhcNMTcwMTA0MTAxMTI3WhcNMzcwMTA0MTAxMTI3WjA3MTUw MwYDVQQDDCxzaGliYm9sZXRoLWludGVncmF0aW9uLW5leHRjbG91ZC5sb2NhbGRv bWFpbjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKH+bPu45tk8/JRk XOxkyfbxocWZlY4mRumEUxscd3fn0oVzOrdWbHH7lCZV4bus4KxvJljc0Nm2K+Zr LoiRUUnf/LQ4LlehWVm5Kbc4kRgOXS0iGZN3SslAWPKyIg0tywg+TLOBPoS6EtST 1WuYg1JPMFxPfeFDWQ0dQYPlXIJWBFh6F2JMTb0FLECqA5l/ryYE13QisX5l+Mqo 6y3Dh7qIgaH0IJNobXoAcEWza7Kb2RnfhZRh9e0qjZIwBqTJUFM/6I86RYXn829s INUvYQQbez6VkGTdUQJ/GuXb/dD5sMQfOyK8hrRY5MozOmK32cz3JaAzSXpiSRS9 NxFwvicCAwEAAaOBoDCBnTAdBgNVHQ4EFgQUKn8+TV0WXSDeavvF0M8mWn1o8ukw fAYDVR0RBHUwc4Isc2hpYmJvbGV0aC1pbnRlZ3JhdGlvbi1uZXh0Y2xvdWQubG9j YWxkb21haW6GQ2h0dHBzOi8vc2hpYmJvbGV0aC1pbnRlZ3JhdGlvbi1uZXh0Y2xv dWQubG9jYWxkb21haW4vaWRwL3NoaWJib2xldGgwDQYJKoZIhvcNAQELBQADggEB ABI6uzoIeLZT9Az2KTlLxIc6jZ4MDmhaVja4ZuBxTXEb7BFLfeASEJmQm1wgIMOn pJId3Kh3njW+3tOBWKm7lj8JxVVpAu4yMFSoQGPaVUgYB1AVm+pmAyPLzfJ/XGhf esCU2F/b0eHWcaIb3x+BZFX089Cd/PBtP84MNXdo+TccibxC8N39sr45qJM/7SC7 TfDYU0L4q2WZHJr4S7+0F+F4KaxLx9NzCvN4h6XaoWofZWir2iHO4NzbrVQGC0ei QybS/neBfni4A2g1lyzCb6xFB58JBvNCn7AAnDJULOE7S5XWUKsDAQVQrxTNkUq7 pnhlCQqZDwUdgmIXd1KB1So=" + And The setting "security-authnRequestsSigned" is set to "1" + And The setting "security-wantAssertionsEncrypted" is set to "1" + And The setting "sp-x509cert" is set to "-----BEGIN CERTIFICATE-----MIIC+zCCAeOgAwIBAgIJAIgZuvWDBIrdMA0GCSqGSIb3DQEBBQUAMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0xNzAxMDQxMTM5MjFaFw0yNzAxMDIxMTM5MjFaMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAN3ESWaDH1JiJTy9yRJQV7kahPOxgBkIH2xwcYDL1k9deKNhSKLx7aGfxE244+HBcC6WLHKVUnOm0ld2qxQ4bMYiJXzZuqL67r07L5wxGAssv12lO92qohGmlHy3+VzRYUBmovu6upqOv3R2F8HBbo7Jc7Hvt7hOEJn/jPuFuF/fHit3mqU8l6IkrIZjpaW8T9fIWOXRq98U4+hkgWpqEZWsqlfE8BxAs9DeIMZab0GxO9stHLp+GYKx10uE4ezFcaDS8W+g2C8enCTt1HXGvcnj4o5zkC1lITGvcFTsiFqfIWyXeSufcxdc0W7HoG6J3ks0WJyK38sfFn0t2Ao6kX0CAwEAAaNQME4wHQYDVR0OBBYEFAoJzX6TVYAwC1GSPe6nObBG54zaMB8GA1UdIwQYMBaAFAoJzX6TVYAwC1GSPe6nObBG54zaMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAJia9R70uXdUZtgujUPjLas4+sVajzlBFmqhBqpLAo934vljf9HISsHrPtdBcbS0d0rucqXwabDf0MlR18ksnT/NYpsTwMbMx76CrXi4zYEEW5lISKEO65aIkzVTcqKWSuhjtSnRdB6iOLsFiKmNMWXaIKMR5T0+AbR9wdQgn08W+3EEeHGvafVQfE3STVsSgNb1ft7DvcSUnfPXGU7KzvmTpZa0Hfmc7uY4vpdEEhLAdRhgLReS7USZskov7ooiPSoD+JRFi2gM4klBxTemHdNUa9oFnHMXuYKOkLbkgFvHxyy+QlLq2ELQTga5e7I83ZyOfGctyf8Ul6vGw10vbQ=-----END CERTIFICATE-----" + And The setting "sp-privateKey" is set to "-----BEGIN RSA PRIVATE KEY-----MIIEpAIBAAKCAQEA3cRJZoMfUmIlPL3JElBXuRqE87GAGQgfbHBxgMvWT114o2FIovHtoZ/ETbjj4cFwLpYscpVSc6bSV3arFDhsxiIlfNm6ovruvTsvnDEYCyy/XaU73aqiEaaUfLf5XNFhQGai+7q6mo6/dHYXwcFujslzse+3uE4Qmf+M+4W4X98eK3eapTyXoiSshmOlpbxP18hY5dGr3xTj6GSBamoRlayqV8TwHECz0N4gxlpvQbE72y0cun4ZgrHXS4Th7MVxoNLxb6DYLx6cJO3Udca9yePijnOQLWUhMa9wVOyIWp8hbJd5K59zF1zRbsegboneSzRYnIrfyx8WfS3YCjqRfQIDAQABAoIBAQC5CQAdcqZ9vLpJNilBCJxJLCFmm+HAAREHD8MErg9A5UK1P4S1wJp/0qieGPi68wXBOTgY2xKSwMycgb04/+NyZidVRu388takOW/+KNBg8pMxdZ6/05GqnI0kivSbR3CXpYuz8hekwhpo9+fWmKjApsHL47ItK6WaeKmPbAFsq1YJGzfp/DXg7LIvh9GA3C1LWWGV7SuCGOyX/2Moi8xRa7qBtH4hDo/0NRhTx7zjYjlBgNEr330pJUopc3+AtHE40R+xMr2zkGvq9RsCZxYxD2VWbLwQW0yNjWmQ2OTuMgJJvk2+N73QLHcB+tea82ZTszsNzRS9DLtc6qbsKEPZAoGBAO78U3vEuRyY56f/1hpo0xuCDwOkWGzgBQWkjJl6dlyVz/zKkhXBHpEYImyt8XRN0W3iGZYpZ2hCFJGTcDp32R6UiEyGLz0Uc8R/tva/TiRVW1FdNczzSHcB24b9OMK4vE9JLs8mA8Rp8YBgtLr5DDuMfYt/a/rZJbg/HIfIN98nAoGBAO2OInCX93t2I6zzRPIqKtI6q6FYNp64VIQjvw9Y8l0x3IdJZRP9H5C8ZhCeYPsgEqTXcXa4j5hL4rQzoUtxfxflBUUH60bcnd4LGaTCMYLS14G011E3GZlIP0sJi5OjEhy8fq3zt6jVzS9V/lPHB8i+w1D7CbPrMpW7B3k32vC7AoGAX/HvdkYhZyjAAEOG6m1hK68IZhbp5TP+8CgCxm9S65K9wKh3A8LXibrdvzIKOP4w8WOPkCipOkMlTNibeu24vj01hztr5aK7Y40+oEtnjNCz67N3MQQO+LBHOSeaTRqrh01DPKjvZECAU2D/zfzEe3fIw2Nxr3DUYub7hkvMmosCgYAzxbVVypjiLGYsDDyrdmsstCKxoDMPNmcdAVljc+QmUXaZeXJw/8qAVb78wjeqo1vM1zNgR2rsKyW2VkZB1fN39q7GU6qAIBa7zLmDAduegmr7VrlSduq6UFeS9/qWa4TIBICrUqFlR2tXdKtgANF+e6y/mmaL8qdsoH1JetXZfwKBgQC1vscRpdAXivjOOZAh+mzJWzS4BUl4CTJLYYIuOEXikmN5g0EdV2fhUEdkewmyKnXHsd0x83167bYgpTDNs71jUxDHy5NXlg2qIjLkf09X9wr19gBzDApfWzfh3vUqttyMZuQMLVNepGCWM2vjlY9KGl5OvZqY6d+7yO0mLV9GmQ==-----END RSA PRIVATE KEY-----" + And The setting "security-wantAssertionsSigned" is set to "1" + And The setting "localGroupsCheckForMigration" is set to '{\"dropAfter\":9223372036854775807,\"groups\":[\"Astrophysics\",\"Students\"]}' + And the local group "Astrophysics" is created + # Initial login so the user is known and belonging to SAML, directly followed by a logout + And I send a GET request to "http://localhost:8080/index.php/login" + And I should be redirected to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO" + And I send a POST request to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO?execution=e1s1" with the following data + |j_username|j_password|_eventId_proceed| + |student1 |password | | + And The response should be a SAML redirect page that gets submitted + And I send a GET request with requesttoken to "http://localhost:8080/index.php/apps/user_saml/saml/sls" + And the cookies are cleared + # Have a second SAML users being known + And I send a GET request to "http://localhost:8080/index.php/login" + And I should be redirected to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO" + And I send a POST request to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO?execution=e1s1" with the following data + |j_username|j_password|_eventId_proceed| + |staff1 |password | | + And The response should be a SAML redirect page that gets submitted + And I send a GET request with requesttoken to "http://localhost:8080/index.php/apps/user_saml/saml/sls" + And the cookies are cleared + # Have a third SAML users being known + And I send a GET request to "http://localhost:8080/index.php/login" + And I should be redirected to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO" + And I send a POST request to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO?execution=e1s1" with the following data + |j_username|j_password|_eventId_proceed| + |student2 |password | | + And The response should be a SAML redirect page that gets submitted + And I send a GET request with requesttoken to "http://localhost:8080/index.php/apps/user_saml/saml/sls" + And the cookies are cleared + # The users are already known as members - student 2 is not + And the user "student1" is added to the group "Astrophysics" + And the user "staff1" is added to the group "Astrophysics" + # Set the proper attributes + And The setting "saml-attribute-mapping-email_mapping" is set to "urn:oid:0.9.2342.19200300.100.1.3" + And The setting "saml-attribute-mapping-displayName_mapping" is set to "urn:oid:2.5.4.42 urn:oid:2.5.4.4" + And The setting "saml-attribute-mapping-group_mapping" is set to "groups" + # Now login in for good + When I send a GET request to "http://localhost:8080/index.php/login" + Then I should be redirected to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO" + And I send a POST request to "https://localhost:4443/idp/profile/SAML2/Redirect/SSO?execution=e1s1" with the following data + |j_username|j_password|_eventId_proceed| + |student1 |password | | + And The response should be a SAML redirect page that gets submitted + And The user value "groups" should be "Astrophysics,SAML_Students" + And the group backend of "Astrophysics" should be "Database" + And Then the group backend of "Astrophysics" must not be "user_saml" + And I execute the background job for class "OCA\\User_SAML\\Jobs\\MigrateGroups" + And the group backend of "Astrophysics" should be "user_saml" + And Then the group backend of "Astrophysics" must not be "Database" + # Recreate state from v6.1 where members where not migrated - terribly awful tinkering + And I "disable" the app "user_saml" + And the local group "Astrophysics" is created + And I hack "student2" into existence + And the user "student2" is added to the group "Astrophysics" + And I "enable" the app "user_saml" + # Now test the occ command, which counts as a test against the repair step as well + Then I run the copy-incomplete-members command + # Remove local group remnants + And I "disable" the app "user_saml" + And the group "Astrophysics" is deleted + And I "enable" the app "user_saml" + # Now the proper checks + And The user value "groups" should be "Astrophysics,SAML_Students" + # student2 will be removed upon proper login, this tests whether the old data is taken over + And The group "Astrophysics" has exactly the members "student1, student2, staff1" + Scenario: Keeping the local admin group assigned to the SAML user Given The setting "type" is set to "saml" And The setting "general-uid_mapping" is set to "urn:oid:0.9.2342.19200300.100.1.1" diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index 4c359169..51f6c872 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -302,6 +302,43 @@ public function theUserValueShouldBe(string $key, string $value): void { } } + /** + * @Then The group :group has exactly the members :memberList + */ + public function theGroupHasExactlyTheMembers(string $group, string $memberList): void { + $this->response = $this->client->request( + 'GET', + sprintf('http://localhost:8080/ocs/v2.php/cloud/groups/%s', $group), + [ + 'headers' => [ + 'OCS-APIRequest' => 'true', + ], + 'query' => [ + 'format' => 'json', + ], + 'auth' => [ + 'admin', 'admin' + ], + 'cookies' => '', + ] + ); + + $responseArray = (json_decode($this->response->getBody(), true))['ocs']; + if ($responseArray['meta']['statuscode'] !== 200) { + throw new UnexpectedValueException(sprintf('Expected 200 status code but got %d', $responseArray['meta']['statusCode'])); + } + + $expectedMembers = array_map('trim', explode(',', $memberList)); + $actualMembers = array_map('trim', $responseArray['data']['users']); + + sort($expectedMembers); + sort($actualMembers); + + if ($expectedMembers !== $actualMembers) { + throw new UnexpectedValueException(sprintf('Unexpectedly the returned members are: %s', implode(', ', $actualMembers))); + } + } + /** * @Given A local user with uid :uid exists * @param string $uid @@ -317,6 +354,22 @@ public function aLocalUserWithUidExists($uid) { ); } + /** + * @Then I hack :uid into existence + */ + public function hackUserIntoExistence(string $uid): void { + rename(__DIR__ . '/../../../../../../data/' . $uid, __DIR__ . '/../../../../../../data/hide-' . $uid); + shell_exec( + sprintf( + 'OC_PASS=password %s %s user:add %s --display-name "Default displayname of '.$uid.'" --password-from-env', + PHP_BINARY, + __DIR__ . '/../../../../../../occ', + $uid + ) + ); + rename(__DIR__ . '/../../../../../../data/hide-' . $uid, __DIR__ . '/../../../../../../data/' . $uid); + } + /** * @Then The last login timestamp of :uid should not be empty * @@ -455,7 +508,19 @@ public function theLocalGroupIsCreated(string $groupName) { ); } - + /** + * @Given the group :group is deleted + */ + public function theGroupIsDeleted(string $group) { + shell_exec( + sprintf( + '%s %s group:delete "%s"', + PHP_BINARY, + __DIR__ . '/../../../../../../occ', + $group + ) + ); + } /** * @Given /^I send a GET request with requesttoken to "([^"]*)"$/ @@ -503,6 +568,37 @@ public function theUserIsAddedToTheGroup(string $userId, string $groupId) { ); } + /** + * @Given I run the copy-incomplete-members command + */ + public function theCopyIncompleteMembersCommandIsRun() { + $out = shell_exec( + sprintf( + '%s %s saml:group-migration:copy-incomplete-members --verbose', + PHP_BINARY, + __DIR__ . '/../../../../../../occ', + ) + ); + if ($out === false || $out === null) { + throw new RuntimeException('Failed to execute saml:group-migration:copy-incomplete-members command'); + } + } + + /** + * @Given I :stateAction the app :appId + */ + public function theAppIsEnabledOrDisabled(string $appId, string $stateAction) { + shell_exec( + sprintf( + '%s %s app:%s "%s"', + PHP_BINARY, + __DIR__ . '/../../../../../../occ', + $stateAction, + $appId + ) + ); + } + /** * @Given /^I expect no background job for class "([^"]*)"$/ */ diff --git a/tests/stub.phpstub b/tests/stub.phpstub index 12a82669..352cb702 100644 --- a/tests/stub.phpstub +++ b/tests/stub.phpstub @@ -9,6 +9,47 @@ namespace OCA\Files\Event { } } +namespace OC\Core\Command { + use Symfony\Component\Console\Command\Command; + use Symfony\Component\Console\Input\InputInterface; + use Symfony\Component\Console\Output\OutputInterface; + + class Base { + public function __construct() {} + protected function configure(): void {} + public function run(InputInterface $input, OutputInterface $output): int {} + public function setName(string $name): self {} + public function setDescription(string $description): self {} + public function addOption(string $name, $shortcut = null, ?int $mode = null, string $description = '', $default = null): self; + public function addArgument(string $name, int $mode = null, string $description = '', $default = null): self; + protected function writeArrayInOutputFormat(InputInterface $input, OutputInterface $output, array $items, string $prefix = ' - '): void; + } +} + +namespace Symfony\Component\Console\Output { + class OutputInterface { + public function isVerbose(): bool; + public function isQuiet(): bool; + public function writeln($messages, int $options = 0): void; + } +} + +namespace Symfony\Component\Console\Input { + class InputInterface { + public function getArgument(string $name): mixed; + public function getOptions(): array; + public function getOption(string $name): mixed; + } + + class InputArgument { + public const REQUIRED = 1; + } + + class InputOption { + public const VALUE_REQUIRED = 2; + } +} + namespace OC\Group { abstract class Database extends \OCP\Group\Backend\ABackend implements \OCP\Group\Backend\IAddToGroupBackend, @@ -24,3 +65,14 @@ namespace OC\Group { \OCP\Group\Backend\INamedBackend { } } + +namespace OC\DB\Exceptions { + class DbalException extends \OCP\DB\Exception { + public function isRetryable(): bool { + } + } +} + +namespace OCP\Log { + public function logger(?string $appId = null): \Psr\Log\LoggerInterface; +}