keycloak-user-group-sync.ts 6.7 KB

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