Skip to content

Commit

Permalink
enh(GroupMigration): add command to run member transfer
Browse files Browse the repository at this point in the history
Signed-off-by: Arthur Schiwon <[email protected]>
  • Loading branch information
blizzz committed Jul 11, 2024
1 parent e2604db commit 8f6eb98
Show file tree
Hide file tree
Showing 6 changed files with 241 additions and 48 deletions.
1 change: 1 addition & 0 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ While theoretically any other authentication provider implementing either one of
<command>OCA\User_SAML\Command\ConfigGet</command>
<command>OCA\User_SAML\Command\ConfigSet</command>
<command>OCA\User_SAML\Command\GetMetadata</command>
<command>OCA\User_SAML\Command\GroupMigrationCopyIncomplete</command>
</commands>
<settings>
<admin>OCA\User_SAML\Settings\Admin</admin>
Expand Down
2 changes: 1 addition & 1 deletion lib/Command/ConfigDelete.php
Original file line number Diff line number Diff line change
Expand Up @@ -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('<error>Provider with id: ' . $providerId . ' does not exist.</error>');
$output->writeln('<error>Provider with id: ' . $pId . ' does not exist.</error>');
return 1;
}
return 0;
Expand Down
93 changes: 93 additions & 0 deletions lib/Command/GroupMigrationCopyIncomplete.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\User_SAML\Command;

use OC\Core\Command\Base;
use OCA\User_SAML\Service\GroupMigration;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;

class GroupMigrationCopyIncomplete extends Base {
public function __construct(
protected GroupMigration $groupMigration,
protected LoggerInterface $logger,
) {
parent::__construct();
}
protected function configure(): void {
$this->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('<info>No pending group member transfer</info>');
}
return 0;
}

if (!$this->doMemberTransfer($groupsToTreat, $output)) {
if (!$output->isQuiet()) {
$output->writeln('<comment>Not all group members could be transferred completely. Rerun this command or check the Nextcloud log.</comment>');
}
return 1;
}

if (!$output->isQuiet()) {
$output->writeln('<info>All group members could be transferred completely.</info>');
}
return 0;
}

/**
* @param string[]|array<empty> $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('<info>Members transferred successfully for group %s</info>', $gid));
}
}
} catch (Throwable $e) {
$errorOccurred = true;
if (!$output->isQuiet()) {
$output->writeln(sprintf('<error>Failed to transfer users from group %s: %s</error>', $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<empty> $groups */
$groups = $retry;
}
if (!empty($groups) && !$output->isQuiet()) {
$output->writeln(sprintf(
'<comment>Members not or incompletely transferred for groups: %s</comment>',
implode(', ', $groups)
));
}
return empty($groups) && !$errorOccurred;
}
}
51 changes: 4 additions & 47 deletions lib/Jobs/MigrateGroups.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@

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 OCP\IUser;
use Psr\Log\LoggerInterface;
use Throwable;

Expand All @@ -44,6 +44,7 @@ class MigrateGroups extends QueuedJob {
private $logger;

public function __construct(
protected GroupMigration $groupMigration,
IConfig $config,
IGroupManager $groupManager,
IDBConnection $dbc,
Expand Down Expand Up @@ -97,7 +98,7 @@ protected function migrateGroup(string $gid): bool {
$isMigrated = false;
$allUsersInserted = false;
try {
$allUsersInserted = $this->migrateGroupUsers($gid);
$allUsersInserted = $this->groupMigration->migrateGroupUsers($gid);

$this->dbc->beginTransaction();

Expand All @@ -121,7 +122,7 @@ protected function migrateGroup(string $gid): bool {

if ($allUsersInserted && $isMigrated) {
try {
$this->cleanUpOldGroupUsers($gid);
$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',
Expand All @@ -134,50 +135,6 @@ protected function migrateGroup(string $gid): bool {
return $isMigrated;
}

/**
* @throws Exception
*/
protected function cleanUpOldGroupUsers(string $gid): void {
$cleanup = $this->dbc->getQueryBuilder();
$cleanup->delete('group_user')
->where($cleanup->expr()->eq('gid', $cleanup->createNamedParameter($gid)));
$cleanup->executeStatement();
}

/**
* @returns bool true when all users were migrated, when they were only partly migrated
* @throws Exception
* @throws Throwable
*/
protected function migrateGroupUsers(string $gid): bool {
$originalGroup = $this->groupManager->get($gid);
$members = $originalGroup?->getUsers();

$areAllInserted = true;
foreach (array_chunk($members ?? [], self::BATCH_SIZE) 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;
}

protected function getGroupsToMigrate(array $samlGroups, array $pool): array {
return array_filter($samlGroups, function (string $gid) use ($pool) {
if (!in_array($gid, $pool)) {
Expand Down
101 changes: 101 additions & 0 deletions lib/Service/GroupMigration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\User_SAML\Service;

use OCA\User_SAML\GroupBackend;
use OCP\AppFramework\Db\TTransactional;
use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
use OCP\IGroupManager;
use OCP\IUser;
use Psr\Log\LoggerInterface;
use Throwable;

class GroupMigration {
use TTransactional;

protected const CHUNK_SIZE = 1000;

public function __construct(
protected GroupBackend $ownGroupBackend,
protected IGroupManager $groupManager,
protected IDBConnection $dbc,
protected LoggerInterface $logger,
) {
}

/**
* @return string[] group ids
*/
public function findGroupsWithLocalMembers(): array {
$foundGroups = [];

$qb = $this->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();
}

}
41 changes: 41 additions & 0 deletions tests/stub.phpstub
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit 8f6eb98

Please sign in to comment.