keycloak-user-group-sync.ts 6.6 KB

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