keycloak-user-group-sync.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. import KeycloakAdminClient from '@keycloak/keycloak-admin-client';
  2. import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation';
  3. import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation';
  4. import { configManager } from '~/server/service/config-manager';
  5. import type { S2sMessagingService } from '~/server/service/s2s-messaging/base';
  6. import loggerFactory from '~/utils/logger';
  7. import { batchProcessPromiseAll } from '~/utils/promise';
  8. import type {
  9. ExternalUserGroupTreeNode,
  10. ExternalUserInfo,
  11. } from '../../interfaces/external-user-group';
  12. import { ExternalGroupProviderType } from '../../interfaces/external-user-group';
  13. import ExternalUserGroupSyncService from './external-user-group-sync';
  14. const logger = loggerFactory('growi:service:keycloak-user-group-sync-service');
  15. // When d = max depth of group trees
  16. // Max space complexity of generateExternalUserGroupTrees will be:
  17. // O(TREES_BATCH_SIZE * d)
  18. const TREES_BATCH_SIZE = 10;
  19. export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
  20. kcAdminClient: KeycloakAdminClient;
  21. realm: string | undefined; // realm that contains the groups
  22. groupDescriptionAttribute: string | undefined; // attribute to map to group description
  23. isInitialized = false;
  24. // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
  25. constructor(
  26. s2sMessagingService: S2sMessagingService | null,
  27. socketIoService,
  28. ) {
  29. super(
  30. ExternalGroupProviderType.keycloak,
  31. s2sMessagingService,
  32. socketIoService,
  33. );
  34. }
  35. init(authProviderType: 'oidc' | 'saml'): void {
  36. const kcHost = configManager.getConfig('external-user-group:keycloak:host');
  37. const kcGroupRealm = configManager.getConfig(
  38. 'external-user-group:keycloak:groupRealm',
  39. );
  40. const kcGroupSyncClientRealm = configManager.getConfig(
  41. 'external-user-group:keycloak:groupSyncClientRealm',
  42. );
  43. const kcGroupDescriptionAttribute = configManager.getConfig(
  44. 'external-user-group:keycloak:groupDescriptionAttribute',
  45. );
  46. this.kcAdminClient = new KeycloakAdminClient({
  47. baseUrl: kcHost,
  48. realmName: kcGroupSyncClientRealm,
  49. });
  50. this.realm = kcGroupRealm;
  51. this.groupDescriptionAttribute = kcGroupDescriptionAttribute;
  52. this.authProviderType = authProviderType;
  53. this.isInitialized = true;
  54. }
  55. override syncExternalUserGroups(): Promise<void> {
  56. if (!this.isInitialized) {
  57. const msg = 'Service not initialized';
  58. logger.error(msg);
  59. throw new Error(msg);
  60. }
  61. return super.syncExternalUserGroups();
  62. }
  63. override async generateExternalUserGroupTrees(): Promise<
  64. ExternalUserGroupTreeNode[]
  65. > {
  66. await this.auth();
  67. // Type is 'GroupRepresentation', but 'find' does not return 'attributes' field. Hence, attribute for description is not present.
  68. logger.info('Get groups from keycloak server');
  69. const rootGroups = await this.kcAdminClient.groups.find({
  70. realm: this.realm,
  71. });
  72. return (
  73. await batchProcessPromiseAll(rootGroups, TREES_BATCH_SIZE, (group) =>
  74. this.groupRepresentationToTreeNode(group),
  75. )
  76. ).filter(
  77. (node): node is NonNullable<ExternalUserGroupTreeNode> => node != null,
  78. );
  79. }
  80. /**
  81. * Authenticate to group sync client using client credentials grant type
  82. */
  83. private async auth(): Promise<void> {
  84. const kcGroupSyncClientID = configManager.getConfig(
  85. 'external-user-group:keycloak:groupSyncClientID',
  86. );
  87. const kcGroupSyncClientSecret = configManager.getConfig(
  88. 'external-user-group:keycloak:groupSyncClientSecret',
  89. );
  90. await this.kcAdminClient.auth({
  91. grantType: 'client_credentials',
  92. clientId: kcGroupSyncClientID ?? '',
  93. clientSecret: kcGroupSyncClientSecret,
  94. });
  95. }
  96. /**
  97. * Convert GroupRepresentation response returned from Keycloak to ExternalUserGroupTreeNode
  98. */
  99. private async groupRepresentationToTreeNode(
  100. group: GroupRepresentation,
  101. ): Promise<ExternalUserGroupTreeNode | null> {
  102. if (group.id == null || group.name == null) return null;
  103. logger.info('Get users from keycloak server');
  104. const userRepresentations = await this.getMembers(group.id);
  105. const userInfos =
  106. userRepresentations != null
  107. ? this.userRepresentationsToExternalUserInfos(userRepresentations)
  108. : [];
  109. const description = (await this.getGroupDescription(group.id)) || undefined;
  110. const childGroups = group.subGroups;
  111. const childGroupNodesWithNull: (ExternalUserGroupTreeNode | null)[] = [];
  112. if (childGroups != null) {
  113. // Do not use Promise.all, because the number of promises processed can
  114. // exponentially grow when group tree is enormous
  115. for await (const childGroup of childGroups) {
  116. childGroupNodesWithNull.push(
  117. await this.groupRepresentationToTreeNode(childGroup),
  118. );
  119. }
  120. }
  121. const childGroupNodes: ExternalUserGroupTreeNode[] =
  122. childGroupNodesWithNull.filter(
  123. (node): node is NonNullable<ExternalUserGroupTreeNode> => node != null,
  124. );
  125. return {
  126. id: group.id,
  127. userInfos,
  128. childGroupNodes,
  129. name: group.name,
  130. description,
  131. };
  132. }
  133. private async getMembers(groupId: string): Promise<UserRepresentation[]> {
  134. let allUsers: UserRepresentation[] = [];
  135. const fetchUsersWithOffset = async (offset: number) => {
  136. await this.auth();
  137. const response = await this.kcAdminClient.groups.listMembers({
  138. id: groupId,
  139. realm: this.realm,
  140. first: offset,
  141. });
  142. if (response != null && response.length > 0) {
  143. allUsers = allUsers.concat(response);
  144. return fetchUsersWithOffset(offset + response.length);
  145. }
  146. };
  147. await fetchUsersWithOffset(0);
  148. return allUsers;
  149. }
  150. /**
  151. * Fetch group detail from Keycloak and return group description
  152. */
  153. private async getGroupDescription(groupId: string): Promise<string | null> {
  154. if (this.groupDescriptionAttribute == null) return null;
  155. await this.auth();
  156. const groupDetail = await this.kcAdminClient.groups.findOne({
  157. id: groupId,
  158. realm: this.realm,
  159. });
  160. const description =
  161. groupDetail?.attributes?.[this.groupDescriptionAttribute]?.[0];
  162. return typeof description === 'string' ? description : null;
  163. }
  164. /**
  165. * Convert UserRepresentation array response returned from Keycloak to ExternalUserInfo
  166. */
  167. private userRepresentationsToExternalUserInfos(
  168. userRepresentations: UserRepresentation[],
  169. ): ExternalUserInfo[] {
  170. const externalUserGroupsWithNull: (ExternalUserInfo | null)[] =
  171. userRepresentations.map((userRepresentation) => {
  172. if (
  173. userRepresentation.id != null &&
  174. userRepresentation.username != null
  175. ) {
  176. return {
  177. id: userRepresentation.id,
  178. username: userRepresentation.username,
  179. email: userRepresentation.email,
  180. };
  181. }
  182. return null;
  183. });
  184. return externalUserGroupsWithNull.filter(
  185. (node): node is NonNullable<ExternalUserInfo> => node != null,
  186. );
  187. }
  188. }