Skip to content

Commit 7bfd59c

Browse files
authored
Merge pull request #1044 from nextcloud/feat/add-command-for-manual-migration
feat: Add command to manually migrate groups from database
2 parents 566cbd2 + ecb744d commit 7bfd59c

11 files changed

Lines changed: 283 additions & 87 deletions

appinfo/info.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ While theoretically any other authentication provider implementing either one of
5858
<command>OCA\User_SAML\Command\ConfigSet</command>
5959
<command>OCA\User_SAML\Command\GetMetadata</command>
6060
<command>OCA\User_SAML\Command\GroupMigrationCopyIncomplete</command>
61+
<command>OCA\User_SAML\Command\GroupMigrationManual</command>
6162
<command>OCA\User_SAML\Command\UserAdd</command>
6263
</commands>
6364
<settings>

lib/Command/ConfigCreate.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ protected function configure(): void {
2626
$this->setDescription('Creates a new config and prints the new provider ID');
2727
}
2828

29+
#[\Override]
2930
protected function execute(InputInterface $input, OutputInterface $output): int {
3031
$output->writeln((string)$this->samlSettings->getNewProviderId());
3132
return 0;

lib/Command/ConfigDelete.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ protected function configure(): void {
3333
);
3434
}
3535

36+
#[\Override]
3637
protected function execute(InputInterface $input, OutputInterface $output): int {
3738
$pId = (int)$input->getArgument('providerId');
3839

lib/Command/ConfigGet.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ protected function configure(): void {
3434
parent::configure();
3535
}
3636

37+
#[\Override]
3738
protected function execute(InputInterface $input, OutputInterface $output): int {
3839
$providerId = (int)$input->getOption('providerId');
3940
if (!empty($providerId)) {

lib/Command/ConfigSet.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ protected function configure(): void {
4444
parent::configure();
4545
}
4646

47+
#[\Override]
4748
protected function execute(InputInterface $input, OutputInterface $output): int {
4849
$pId = (int)$input->getArgument('providerId');
4950

lib/Command/GroupMigrationCopyIncomplete.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ protected function configure(): void {
2727
$this->setDescription('Transfers remaining group members from old local to current SAML groups');
2828
}
2929

30+
#[\Override]
3031
protected function execute(InputInterface $input, OutputInterface $output): int {
3132
$groupsToTreat = $this->groupMigration->findGroupsWithLocalMembers();
3233
if (empty($groupsToTreat)) {
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\User_SAML\Command;
11+
12+
use OC\Core\Command\Base;
13+
use OC\Group\Database;
14+
use OCA\User_SAML\Service\GroupMigration;
15+
use OCP\IGroupManager;
16+
use Psr\Log\LoggerInterface;
17+
use Psr\Log\LogLevel;
18+
use Symfony\Component\Console\Input\InputInterface;
19+
use Symfony\Component\Console\Output\OutputInterface;
20+
21+
class GroupMigrationManual extends Base implements LoggerInterface {
22+
private ?OutputInterface $output = null;
23+
24+
public function __construct(
25+
private readonly GroupMigration $groupMigration,
26+
private readonly IGroupManager $groupManager,
27+
) {
28+
parent::__construct();
29+
$this->groupMigration->setLogger($this);
30+
}
31+
32+
#[\Override]
33+
protected function configure(): void {
34+
$this->setName('saml:group-migration:force');
35+
$this->setDescription('Force migration of all groups from Database to SAML backend. Groups with non-saml members are skipped.');
36+
}
37+
38+
#[\Override]
39+
protected function execute(InputInterface $input, OutputInterface $output): int {
40+
$this->output = $output;
41+
try {
42+
$backend = $this->findBackend();
43+
$groupsToTreat = $this->findGroupIds($backend);
44+
$groupsToTreat = $this->groupMigration->getGroupsToMigrate($groupsToTreat, $groupsToTreat);
45+
} catch (\UnexpectedValueException) {
46+
$output->writeln('<error>Failed to find database group backend</error>');
47+
return self::FAILURE;
48+
}
49+
50+
$failures = 0;
51+
foreach ($groupsToTreat as $group) {
52+
if (!$this->groupMigration->migrateGroup($group)) {
53+
$output->writeln('<error>Failed to migrate ' . $group . '</error>');
54+
$failures++;
55+
}
56+
}
57+
if ($failures === 0) {
58+
$output->writeln('<info>All groups were successfully migrated</info>');
59+
return self::SUCCESS;
60+
}
61+
62+
return self::FAILURE;
63+
}
64+
65+
private function findBackend(): Database {
66+
$groupBackends = $this->groupManager->getBackends();
67+
foreach ($groupBackends as $backend) {
68+
if ($backend instanceof Database) {
69+
return $backend;
70+
}
71+
}
72+
throw new \UnexpectedValueException();
73+
}
74+
75+
private function findGroupIds(Database $backend): array {
76+
$groupIds = $backend->getGroups();
77+
$adminGroupIndex = array_search('admin', $groupIds, true);
78+
if ($adminGroupIndex !== false) {
79+
unset($groupIds[$adminGroupIndex]);
80+
}
81+
return array_values($groupIds);
82+
}
83+
84+
/*
85+
* Log functions to implement LoggerInterface so that information makes it to the cli output
86+
*/
87+
88+
/**
89+
* @param string $message
90+
*/
91+
#[\Override]
92+
public function emergency($message, array $context = []): void {
93+
$this->log(LogLevel::EMERGENCY, $message, $context);
94+
}
95+
96+
/**
97+
* @param string $message
98+
*/
99+
#[\Override]
100+
public function alert($message, array $context = []): void {
101+
$this->log(LogLevel::ALERT, $message, $context);
102+
}
103+
104+
/**
105+
* @param string $message
106+
*/
107+
#[\Override]
108+
public function critical($message, array $context = []): void {
109+
$this->log(LogLevel::CRITICAL, $message, $context);
110+
}
111+
112+
/**
113+
* @param string $message
114+
*/
115+
#[\Override]
116+
public function error($message, array $context = []): void {
117+
$this->log(LogLevel::ERROR, $message, $context);
118+
}
119+
120+
/**
121+
* @param string $message
122+
*/
123+
#[\Override]
124+
public function warning($message, array $context = []): void {
125+
$this->log(LogLevel::WARNING, $message, $context);
126+
}
127+
128+
/**
129+
* @param string $message
130+
*/
131+
#[\Override]
132+
public function notice($message, array $context = []): void {
133+
$this->log(LogLevel::NOTICE, $message, $context);
134+
}
135+
136+
/**
137+
* @param string $message
138+
*/
139+
#[\Override]
140+
public function info($message, array $context = []): void {
141+
$this->log(LogLevel::INFO, $message, $context);
142+
}
143+
144+
/**
145+
* @param string $message
146+
*/
147+
#[\Override]
148+
public function debug($message, array $context = []): void {
149+
$this->log(LogLevel::DEBUG, $message, $context);
150+
}
151+
152+
/**
153+
* Logs with an arbitrary level.
154+
*
155+
* @param mixed $level
156+
* @param string $message
157+
*/
158+
#[\Override]
159+
public function log($level, $message, array $context = []): void {
160+
$tag = match($level) {
161+
LogLevel::EMERGENCY,
162+
LogLevel::ALERT,
163+
LogLevel::CRITICAL,
164+
LogLevel::ERROR => 'error',
165+
LogLevel::WARNING,
166+
LogLevel::NOTICE => 'warning',
167+
LogLevel::INFO => 'info',
168+
default => '',
169+
};
170+
$flags = match($level) {
171+
LogLevel::DEBUG => OutputInterface::VERBOSITY_VERBOSE,
172+
default => 0,
173+
};
174+
$message = $this->interpolateMessage($message, $context);
175+
if ($tag !== '') {
176+
$message = '<' . $tag . '>' . $message . '<' . $tag . '/>';
177+
}
178+
$this->output?->writeln($message, $flags);
179+
}
180+
181+
private function interpolateMessage(string $message, array $context): string {
182+
$replace = [];
183+
foreach ($context as $key => $val) {
184+
$replace['{' . $key . '}'] = $val;
185+
}
186+
return strtr($message, $replace);
187+
}
188+
}

lib/Command/UserAdd.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ protected function configure(): void {
4848
);
4949
}
5050

51+
#[\Override]
5152
protected function execute(InputInterface $input, OutputInterface $output): int {
5253
$uid = $input->getArgument('uid');
5354

lib/Jobs/MigrateGroups.php

Lines changed: 2 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,10 @@
1414
use OCP\AppFramework\Db\TTransactional;
1515
use OCP\AppFramework\Utility\ITimeFactory;
1616
use OCP\BackgroundJob\QueuedJob;
17-
use OCP\DB\Exception;
1817
use OCP\IConfig;
1918
use OCP\IDBConnection;
2019
use OCP\IGroupManager;
2120
use Psr\Log\LoggerInterface;
22-
use Throwable;
2321

2422
/**
2523
* Class MigrateGroups
@@ -49,7 +47,7 @@ public function __construct(
4947
protected function run($argument) {
5048
try {
5149
$candidates = $this->getMigratableGroups();
52-
$toMigrate = $this->getGroupsToMigrate($argument['gids'], $candidates);
50+
$toMigrate = $this->groupMigration->getGroupsToMigrate($argument['gids'], $candidates);
5351
$migrated = $this->migrateGroups($toMigrate);
5452
$this->ownGroupManager->updateCandidatePool($migrated);
5553
} catch (\RuntimeException) {
@@ -58,89 +56,7 @@ protected function run($argument) {
5856
}
5957

6058
protected function migrateGroups(array $toMigrate): array {
61-
return array_filter($toMigrate, fn ($gid) => $this->migrateGroup($gid));
62-
}
63-
64-
protected function migrateGroup(string $gid): bool {
65-
$isMigrated = false;
66-
$allUsersInserted = false;
67-
try {
68-
$allUsersInserted = $this->groupMigration->migrateGroupUsers($gid);
69-
70-
$this->dbc->beginTransaction();
71-
72-
$qb = $this->dbc->getQueryBuilder();
73-
$affected = $qb->delete('groups')
74-
->where($qb->expr()->eq('gid', $qb->createNamedParameter($gid)))
75-
->executeStatement();
76-
if ($affected === 0) {
77-
throw new \RuntimeException('Could not delete group from local backend');
78-
}
79-
if (!$this->ownGroupBackend->createGroup($gid)) {
80-
throw new \RuntimeException('Could not create group in SAML backend');
81-
}
82-
83-
$this->dbc->commit();
84-
$isMigrated = true;
85-
} catch (Throwable $e) {
86-
$this->dbc->rollBack();
87-
$this->logger->warning($e->getMessage(), ['app' => 'user_saml', 'exception' => $e]);
88-
}
89-
90-
if ($allUsersInserted && $isMigrated) {
91-
try {
92-
$this->groupMigration->cleanUpOldGroupUsers($gid);
93-
} catch (Exception $e) {
94-
$this->logger->warning('Error while cleaning up group members in (oc_)group_user of group (gid) {gid}', [
95-
'app' => 'user_saml',
96-
'gid' => $gid,
97-
'exception' => $e,
98-
]);
99-
}
100-
}
101-
102-
return $isMigrated;
103-
}
104-
105-
protected function getGroupsToMigrate(array $samlGroups, array $pool): array {
106-
return array_filter($samlGroups, function (string $gid) use ($pool) {
107-
if (!in_array($gid, $pool)) {
108-
return false;
109-
}
110-
111-
$group = $this->groupManager->get($gid);
112-
if ($group === null) {
113-
$this->logger->debug('Not migrating group "{gid}": not found by the group manager', [
114-
'app' => 'user_saml',
115-
'gid' => $gid,
116-
]);
117-
return false;
118-
}
119-
120-
$backendNames = $group->getBackendNames();
121-
if (!in_array('Database', $backendNames, true)) {
122-
$this->logger->debug('Not migrating group "{gid}": not belonging to local database backend', [
123-
'app' => 'user_saml',
124-
'gid' => $gid,
125-
'backends' => $backendNames,
126-
]);
127-
return false;
128-
}
129-
130-
foreach ($group->getUsers() as $user) {
131-
if ($user->getBackendClassName() !== 'user_saml') {
132-
$this->logger->debug('Not migrating group "{gid}": user "{userId}" from a different backend "{userBackend}"', [
133-
'app' => 'user_saml',
134-
'gid' => $gid,
135-
'userId' => $user->getUID(),
136-
'userBackend' => $user->getBackendClassName(),
137-
]);
138-
return false;
139-
}
140-
}
141-
142-
return true;
143-
});
59+
return array_filter($toMigrate, fn ($gid) => $this->groupMigration->migrateGroup($gid));
14460
}
14561

14662
protected function getMigratableGroups(): array {

0 commit comments

Comments
 (0)