Version1130Date20211102154716.php 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
  5. * SPDX-License-Identifier: AGPL-3.0-or-later
  6. */
  7. namespace OCA\User_LDAP\Migration;
  8. use Closure;
  9. use Generator;
  10. use OCP\DB\Exception;
  11. use OCP\DB\ISchemaWrapper;
  12. use OCP\DB\QueryBuilder\IQueryBuilder;
  13. use OCP\DB\Types;
  14. use OCP\IDBConnection;
  15. use OCP\Migration\IOutput;
  16. use OCP\Migration\SimpleMigrationStep;
  17. use Psr\Log\LoggerInterface;
  18. class Version1130Date20211102154716 extends SimpleMigrationStep {
  19. /** @var string[] */
  20. private $hashColumnAddedToTables = [];
  21. public function __construct(
  22. private IDBConnection $dbc,
  23. private LoggerInterface $logger,
  24. ) {
  25. }
  26. public function getName() {
  27. return 'Adjust LDAP user and group ldap_dn column lengths and add ldap_dn_hash columns';
  28. }
  29. public function preSchemaChange(IOutput $output, \Closure $schemaClosure, array $options) {
  30. foreach (['ldap_user_mapping', 'ldap_group_mapping'] as $tableName) {
  31. $this->processDuplicateUUIDs($tableName);
  32. }
  33. /** @var ISchemaWrapper $schema */
  34. $schema = $schemaClosure();
  35. if ($schema->hasTable('ldap_group_mapping_backup')) {
  36. // Previous upgrades of a broken release might have left an incomplete
  37. // ldap_group_mapping_backup table. No need to recreate, but it
  38. // should be empty.
  39. // TRUNCATE is not available from Query Builder, but faster than DELETE FROM.
  40. $sql = $this->dbc->getDatabasePlatform()->getTruncateTableSQL('`*PREFIX*ldap_group_mapping_backup`', false);
  41. $this->dbc->executeStatement($sql);
  42. }
  43. }
  44. /**
  45. * @param IOutput $output
  46. * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
  47. * @param array $options
  48. * @return null|ISchemaWrapper
  49. */
  50. public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
  51. /** @var ISchemaWrapper $schema */
  52. $schema = $schemaClosure();
  53. $changeSchema = false;
  54. foreach (['ldap_user_mapping', 'ldap_group_mapping'] as $tableName) {
  55. $table = $schema->getTable($tableName);
  56. if (!$table->hasColumn('ldap_dn_hash')) {
  57. $table->addColumn('ldap_dn_hash', Types::STRING, [
  58. 'notnull' => false,
  59. 'length' => 64,
  60. ]);
  61. $changeSchema = true;
  62. $this->hashColumnAddedToTables[] = $tableName;
  63. }
  64. $column = $table->getColumn('ldap_dn');
  65. if ($tableName === 'ldap_user_mapping') {
  66. if ($column->getLength() < 4000) {
  67. $column->setLength(4000);
  68. $changeSchema = true;
  69. }
  70. if ($table->hasIndex('ldap_dn_users')) {
  71. $table->dropIndex('ldap_dn_users');
  72. $changeSchema = true;
  73. }
  74. if (!$table->hasIndex('ldap_user_dn_hashes')) {
  75. $table->addUniqueIndex(['ldap_dn_hash'], 'ldap_user_dn_hashes');
  76. $changeSchema = true;
  77. }
  78. if (!$table->hasIndex('ldap_user_directory_uuid')) {
  79. $table->addUniqueIndex(['directory_uuid'], 'ldap_user_directory_uuid');
  80. $changeSchema = true;
  81. }
  82. } elseif (!$schema->hasTable('ldap_group_mapping_backup')) {
  83. // We need to copy the table twice to be able to change primary key, prepare the backup table
  84. $table2 = $schema->createTable('ldap_group_mapping_backup');
  85. $table2->addColumn('ldap_dn', Types::STRING, [
  86. 'notnull' => true,
  87. 'length' => 4000,
  88. 'default' => '',
  89. ]);
  90. $table2->addColumn('owncloud_name', Types::STRING, [
  91. 'notnull' => true,
  92. 'length' => 64,
  93. 'default' => '',
  94. ]);
  95. $table2->addColumn('directory_uuid', Types::STRING, [
  96. 'notnull' => true,
  97. 'length' => 255,
  98. 'default' => '',
  99. ]);
  100. $table2->addColumn('ldap_dn_hash', Types::STRING, [
  101. 'notnull' => false,
  102. 'length' => 64,
  103. ]);
  104. $table2->setPrimaryKey(['owncloud_name'], 'lgm_backup_primary');
  105. $changeSchema = true;
  106. }
  107. }
  108. return $changeSchema ? $schema : null;
  109. }
  110. /**
  111. * @param IOutput $output
  112. * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
  113. * @param array $options
  114. */
  115. public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options) {
  116. $this->handleDNHashes('ldap_group_mapping');
  117. $this->handleDNHashes('ldap_user_mapping');
  118. }
  119. protected function handleDNHashes(string $table): void {
  120. $select = $this->getSelectQuery($table);
  121. $update = $this->getUpdateQuery($table);
  122. $result = $select->executeQuery();
  123. while ($row = $result->fetch()) {
  124. $dnHash = hash('sha256', $row['ldap_dn'], false);
  125. $update->setParameter('name', $row['owncloud_name']);
  126. $update->setParameter('dn_hash', $dnHash);
  127. try {
  128. $update->executeStatement();
  129. } catch (Exception $e) {
  130. $this->logger->error('Failed to add hash "{dnHash}" ("{name}" of {table})',
  131. [
  132. 'app' => 'user_ldap',
  133. 'name' => $row['owncloud_name'],
  134. 'dnHash' => $dnHash,
  135. 'table' => $table,
  136. 'exception' => $e,
  137. ]
  138. );
  139. }
  140. }
  141. $result->closeCursor();
  142. }
  143. protected function getSelectQuery(string $table): IQueryBuilder {
  144. $qb = $this->dbc->getQueryBuilder();
  145. $qb->select('owncloud_name', 'ldap_dn')
  146. ->from($table);
  147. // when added we may run into risk that it's read from a DB node
  148. // where the column is not present. Then the where clause is also
  149. // not necessary since all rows qualify.
  150. if (!in_array($table, $this->hashColumnAddedToTables, true)) {
  151. $qb->where($qb->expr()->isNull('ldap_dn_hash'));
  152. }
  153. return $qb;
  154. }
  155. protected function getUpdateQuery(string $table): IQueryBuilder {
  156. $qb = $this->dbc->getQueryBuilder();
  157. $qb->update($table)
  158. ->set('ldap_dn_hash', $qb->createParameter('dn_hash'))
  159. ->where($qb->expr()->eq('owncloud_name', $qb->createParameter('name')));
  160. return $qb;
  161. }
  162. /**
  163. * @throws Exception
  164. */
  165. protected function processDuplicateUUIDs(string $table): void {
  166. $uuids = $this->getDuplicatedUuids($table);
  167. $idsWithUuidToInvalidate = [];
  168. foreach ($uuids as $uuid) {
  169. array_push($idsWithUuidToInvalidate, ...$this->getNextcloudIdsByUuid($table, $uuid));
  170. }
  171. $this->invalidateUuids($table, $idsWithUuidToInvalidate);
  172. }
  173. /**
  174. * @throws Exception
  175. */
  176. protected function invalidateUuids(string $table, array $idList): void {
  177. $update = $this->dbc->getQueryBuilder();
  178. $update->update($table)
  179. ->set('directory_uuid', $update->createParameter('invalidatedUuid'))
  180. ->where($update->expr()->eq('owncloud_name', $update->createParameter('nextcloudId')));
  181. while ($nextcloudId = array_shift($idList)) {
  182. $update->setParameter('nextcloudId', $nextcloudId);
  183. $update->setParameter('invalidatedUuid', 'invalidated_' . \bin2hex(\random_bytes(6)));
  184. try {
  185. $update->executeStatement();
  186. $this->logger->warning(
  187. '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.',
  188. [
  189. 'app' => 'user_ldap',
  190. 'nid' => $nextcloudId,
  191. ]
  192. );
  193. } catch (Exception $e) {
  194. // Catch possible, but unlikely duplications if new invalidated errors.
  195. // There is the theoretical chance of an infinity loop is, when
  196. // the constraint violation has a different background. I cannot
  197. // think of one at the moment.
  198. if ($e->getReason() !== Exception::REASON_CONSTRAINT_VIOLATION) {
  199. throw $e;
  200. }
  201. $idList[] = $nextcloudId;
  202. }
  203. }
  204. }
  205. /**
  206. * @throws \OCP\DB\Exception
  207. * @return array<string>
  208. */
  209. protected function getNextcloudIdsByUuid(string $table, string $uuid): array {
  210. $select = $this->dbc->getQueryBuilder();
  211. $select->select('owncloud_name')
  212. ->from($table)
  213. ->where($select->expr()->eq('directory_uuid', $select->createNamedParameter($uuid)));
  214. $result = $select->executeQuery();
  215. $idList = [];
  216. while (($id = $result->fetchOne()) !== false) {
  217. $idList[] = $id;
  218. }
  219. $result->closeCursor();
  220. return $idList;
  221. }
  222. /**
  223. * @return Generator<string>
  224. * @throws \OCP\DB\Exception
  225. */
  226. protected function getDuplicatedUuids(string $table): Generator {
  227. $select = $this->dbc->getQueryBuilder();
  228. $select->select('directory_uuid')
  229. ->from($table)
  230. ->groupBy('directory_uuid')
  231. ->having($select->expr()->gt($select->func()->count('owncloud_name'), $select->createNamedParameter(1)));
  232. $result = $select->executeQuery();
  233. while (($uuid = $result->fetchOne()) !== false) {
  234. yield $uuid;
  235. }
  236. $result->closeCursor();
  237. }
  238. }