external-user-group-sync.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. import type { IUserHasId } from '@growi/core';
  2. import { SocketEventName } from '~/interfaces/websocket';
  3. import ExternalAccount from '~/server/models/external-account';
  4. import S2sMessage from '~/server/models/vo/s2s-message';
  5. import { S2sMessagingService } from '~/server/service/s2s-messaging/base';
  6. import { S2sMessageHandlable } from '~/server/service/s2s-messaging/handlable';
  7. import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
  8. import loggerFactory from '~/utils/logger';
  9. import { batchProcessPromiseAll } from '~/utils/promise';
  10. import { configManager } from '../../../../server/service/config-manager';
  11. import { externalAccountService } from '../../../../server/service/external-account';
  12. import {
  13. ExternalGroupProviderType, ExternalUserGroupTreeNode, ExternalUserInfo, IExternalUserGroupHasId,
  14. } from '../../interfaces/external-user-group';
  15. import ExternalUserGroup from '../models/external-user-group';
  16. import ExternalUserGroupRelation from '../models/external-user-group-relation';
  17. const logger = loggerFactory('growi:service:external-user-group-sync-service');
  18. // When d = max depth of group trees
  19. // Max space complexity of syncExternalUserGroups will be:
  20. // O(TREES_BATCH_SIZE * d * USERS_BATCH_SIZE)
  21. const TREES_BATCH_SIZE = 10;
  22. const USERS_BATCH_SIZE = 30;
  23. class ExternalUserGroupSyncS2sMessage extends S2sMessage {
  24. isExecutingSync: boolean;
  25. }
  26. abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
  27. groupProviderType: ExternalGroupProviderType; // name of external service that contains user group info (e.g: ldap, keycloak)
  28. authProviderType: string | null; // auth provider type (e.g: ldap, oidc). Has to be set before syncExternalUserGroups execution.
  29. socketIoService: any;
  30. s2sMessagingService: S2sMessagingService | null;
  31. isExecutingSync = false;
  32. // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
  33. constructor(groupProviderType: ExternalGroupProviderType, s2sMessagingService: S2sMessagingService | null, socketIoService) {
  34. this.groupProviderType = groupProviderType;
  35. this.s2sMessagingService = s2sMessagingService;
  36. this.socketIoService = socketIoService;
  37. }
  38. /**
  39. * @inheritdoc
  40. */
  41. shouldHandleS2sMessage(s2sMessage: ExternalUserGroupSyncS2sMessage): boolean {
  42. return s2sMessage.eventName === 'switchExternalUserGroupExecSyncStatus';
  43. }
  44. /**
  45. * @inheritdoc
  46. */
  47. async handleS2sMessage(s2sMessage: ExternalUserGroupSyncS2sMessage): Promise<void> {
  48. logger.info(`Set isExecutingSync to ${s2sMessage.isExecutingSync} by pubsub notification`);
  49. this.isExecutingSync = s2sMessage.isExecutingSync;
  50. }
  51. async switchIsExecutingSync(isExecutingSync: boolean): Promise<void> {
  52. this.isExecutingSync = isExecutingSync;
  53. if (this.s2sMessagingService != null) {
  54. const s2sMessage = new ExternalUserGroupSyncS2sMessage('switchExternalUserGroupExecSyncStatus', {
  55. isExecutingSync,
  56. });
  57. try {
  58. await this.s2sMessagingService.publish(s2sMessage);
  59. }
  60. catch (e) {
  61. logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
  62. }
  63. }
  64. }
  65. /** External user group tree sync method
  66. * 1. Generate external user group tree
  67. * 2. Use createUpdateExternalUserGroup on each node in the tree using DFS
  68. * 3. If preserveDeletedLDAPGroups is false、delete all ExternalUserGroups that were not found during tree search
  69. */
  70. async syncExternalUserGroups(): Promise<void> {
  71. if (this.authProviderType == null) throw new Error('auth provider type is not set');
  72. if (this.isExecutingSync) throw new Error('External user group sync is already being executed');
  73. await this.switchIsExecutingSync(true);
  74. const preserveDeletedLdapGroups: boolean = configManager?.getConfig('crowi', `external-user-group:${this.groupProviderType}:preserveDeletedGroups`);
  75. const existingExternalUserGroupIds: string[] = [];
  76. const socket = this.socketIoService?.getAdminSocket();
  77. try {
  78. const trees = await this.generateExternalUserGroupTrees();
  79. const totalCount = trees.map(tree => this.getGroupCountOfTree(tree))
  80. .reduce((sum, current) => sum + current);
  81. let count = 0;
  82. const syncNode = async(node: ExternalUserGroupTreeNode, parentId?: string) => {
  83. const externalUserGroup = await this.createUpdateExternalUserGroup(node, parentId);
  84. existingExternalUserGroupIds.push(externalUserGroup._id);
  85. count++;
  86. socket?.emit(SocketEventName.externalUserGroup[this.groupProviderType].GroupSyncProgress, { totalCount, count });
  87. // Do not use Promise.all, because the number of promises processed can
  88. // exponentially grow when group tree is enormous
  89. for await (const childNode of node.childGroupNodes) {
  90. await syncNode(childNode, externalUserGroup._id);
  91. }
  92. };
  93. await batchProcessPromiseAll(trees, TREES_BATCH_SIZE, async(tree) => {
  94. return syncNode(tree);
  95. });
  96. if (!preserveDeletedLdapGroups) {
  97. await ExternalUserGroup.deleteMany({ _id: { $nin: existingExternalUserGroupIds }, groupProviderType: this.groupProviderType });
  98. await ExternalUserGroupRelation.removeAllInvalidRelations();
  99. }
  100. socket?.emit(SocketEventName.externalUserGroup[this.groupProviderType].GroupSyncCompleted);
  101. }
  102. catch (e) {
  103. logger.error(e.message);
  104. socket?.emit(SocketEventName.externalUserGroup[this.groupProviderType].GroupSyncFailed);
  105. }
  106. finally {
  107. await this.switchIsExecutingSync(false);
  108. }
  109. }
  110. /** External user group node sync method
  111. * 1. Create/Update ExternalUserGroup from using information of ExternalUserGroupTreeNode
  112. * 2. For every element in node.userInfos, call getMemberUser and create an ExternalUserGroupRelation with ExternalUserGroup if it does not have one
  113. * 3. Retrun ExternalUserGroup
  114. * @param {string} node Node of external group tree
  115. * @param {string} parentId Parent group id (id in GROWI) of the group we want to create/update
  116. * @returns {Promise<IExternalUserGroupHasId>} ExternalUserGroup that was created/updated
  117. */
  118. private async createUpdateExternalUserGroup(node: ExternalUserGroupTreeNode, parentId?: string): Promise<IExternalUserGroupHasId> {
  119. const externalUserGroup = await ExternalUserGroup.findAndUpdateOrCreateGroup(
  120. node.name, node.id, this.groupProviderType, node.description, parentId,
  121. );
  122. await batchProcessPromiseAll(node.userInfos, USERS_BATCH_SIZE, async(userInfo) => {
  123. const user = await this.getMemberUser(userInfo);
  124. if (user != null) {
  125. const userGroups = await ExternalUserGroup.findGroupsWithAncestorsRecursively(externalUserGroup);
  126. const userGroupIds = userGroups.map(g => g._id);
  127. // remove existing relations from list to create
  128. const existingRelations = await ExternalUserGroupRelation.find({ relatedGroup: { $in: userGroupIds }, relatedUser: user._id });
  129. const existingGroupIds = existingRelations.map(r => r.relatedGroup.toString());
  130. const groupIdsToCreateRelation = excludeTestIdsFromTargetIds(userGroupIds, existingGroupIds);
  131. await ExternalUserGroupRelation.createRelations(groupIdsToCreateRelation, user);
  132. }
  133. });
  134. return externalUserGroup;
  135. }
  136. /** Method to get group member GROWI user
  137. * 1. Search for GROWI user based on user info of 1, and return user
  138. * 2. If autoGenerateUserOnHogeGroupSync is true and GROWI user is not found, create new GROWI user
  139. * @param {ExternalUserInfo} externalUserInfo Search external app/server using this identifier
  140. * @returns {Promise<IUserHasId | null>} User when found or created, null when neither
  141. */
  142. private async getMemberUser(userInfo: ExternalUserInfo): Promise<IUserHasId | null> {
  143. const authProviderType = this.authProviderType;
  144. if (authProviderType == null) throw new Error('auth provider type is not set');
  145. const autoGenerateUserOnGroupSync = configManager?.getConfig('crowi', `external-user-group:${this.groupProviderType}:autoGenerateUserOnGroupSync`);
  146. const getExternalAccount = async() => {
  147. if (autoGenerateUserOnGroupSync && externalAccountService != null) {
  148. return externalAccountService.getOrCreateUser({
  149. id: userInfo.id, username: userInfo.username, name: userInfo.name, email: userInfo.email,
  150. }, authProviderType);
  151. }
  152. return ExternalAccount.findOne({ providerType: this.groupProviderType, accountId: userInfo.id });
  153. };
  154. const externalAccount = await getExternalAccount();
  155. if (externalAccount != null) {
  156. return (await externalAccount.populate<{user: IUserHasId | null}>('user')).user;
  157. }
  158. return null;
  159. }
  160. getGroupCountOfTree(tree: ExternalUserGroupTreeNode): number {
  161. if (tree.childGroupNodes.length === 0) return 1;
  162. let count = 1;
  163. tree.childGroupNodes.forEach((childGroup) => {
  164. count += this.getGroupCountOfTree(childGroup);
  165. });
  166. return count;
  167. }
  168. /** Method to generate external group tree structure
  169. * 1. Fetch user group info from external app/server
  170. * 2. Convert each group tree structure to ExternalUserGroupTreeNode
  171. * 3. Return the root node of each tree
  172. */
  173. abstract generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]>
  174. }
  175. export default ExternalUserGroupSyncService;