Explorar o código

keycloak user group sync service

Futa Arai %!s(int64=2) %!d(string=hai) anos
pai
achega
0112c603fe

+ 2 - 1
apps/app/package.json

@@ -76,10 +76,11 @@
     "@growi/slack": "link:../../packages/slack",
     "@promster/express": "^7.0.6",
     "@promster/server": "^7.0.8",
+    "@s3pweb/keycloak-admin-client-cjs": "^22.0.1",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
-    "@types/ldapjs": "^2.2.5",
     "@types/jest": "^29.5.2",
+    "@types/ldapjs": "^2.2.5",
     "JSONStream": "^1.3.5",
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",

+ 1 - 1
apps/app/public/static/locales/en_US/admin.json

@@ -1059,6 +1059,7 @@
     "invalid_sync_settings": "Invalid sync settings",
     "description_form_detail": "Please note that edited value will be overwritten on next sync if description mapper is set in sync settings",
     "only_description_edit_allowed": "Only description can be edited for external user groups",
+    "sync_succeeded": "Sync succeeded",
     "ldap": {
       "group_sync_settings": "LDAP Group Sync Settings",
       "group_search_base_DN": "Group Search Base DN",
@@ -1072,7 +1073,6 @@
       "preserve_deleted_ldap_groups": "Preserve Deleted LDAP Groups",
       "name_mapper_detail": "Attribute to map as group name",
       "updated_group_sync_settings": "Updated LDAP group sync settings",
-      "sync_succeeded": "Sync succeeded",
       "password": "Password",
       "password_detail": "Login password is necessary because Bind type is set to User Bind",
       "circular_reference": "Sync failed because there is a possible circular reference in your LDAP group tree structure",

+ 1 - 1
apps/app/public/static/locales/ja_JP/admin.json

@@ -1067,6 +1067,7 @@
     "invalid_sync_settings": "同期設定に誤りがあります",
     "description_form_detail": "同期設定で「説明」の mapper が設定されている場合、編集内容は再同期によって上書きされることに注意してください",
     "only_description_edit_allowed": "外部グループは説明の編集のみが可能です",
+    "sync_succeeded": "同期に成功しました",
     "ldap": {
       "group_sync_settings": "LDAP グループ同期設定",
       "group_search_base_DN": "グループ検索ベース DN",
@@ -1080,7 +1081,6 @@
       "preserve_deleted_ldap_groups": "LDAP から削除されたグループを GROWI に残す",
       "name_mapper_detail": "グループの「名前」として読み込む属性",
       "updated_group_sync_settings": "LDAP グループ同期設定を更新しました",
-      "sync_succeeded": "同期に成功しました",
       "password": "パスワード",
       "password_detail": "認証設定がユーザ Bind のため、ログイン時のパスワードの入力が必要となります",
       "circular_reference": "LDAP グループの木構造に循環参照が行われている可能性があるため、同期に失敗しました",

+ 1 - 1
apps/app/public/static/locales/zh_CN/admin.json

@@ -1067,6 +1067,7 @@
     "invalid_sync_settings": "Invalid sync settings",
     "description_form_detail": "Please note that edited value will be overwritten on next sync if description mapper is set in sync settings",
     "only_description_edit_allowed": "Only description can be edited for external user groups",
+    "sync_succeeded": "Sync succeeded",
     "ldap": {
       "group_sync_settings": "LDAP Group Sync Settings",
       "group_search_base_DN": "Group Search Base DN",
@@ -1080,7 +1081,6 @@
       "preserve_deleted_ldap_groups": "Preserve Deleted LDAP Groups",
       "name_mapper_detail": "Attribute to map as group name",
       "updated_group_sync_settings": "Updated LDAP group sync settings",
-      "sync_succeeded": "Sync succeeded",
       "password": "Password",
       "password_detail": "Login password is necessary because Bind type is set to User Bind",
       "circular_reference": "Sync failed because there is a possible circular reference in your LDAP group tree structure",

+ 25 - 1
apps/app/src/features/external-user-group/client/components/ExternalUserGroup/KeycloakGroupManagement.tsx

@@ -1,12 +1,36 @@
-import { FC } from 'react';
+import { FC, useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 
 import { KeycloakGroupSyncSettingsForm } from './KeycloakGroupSyncSettingsForm';
 
 export const KeycloakGroupManagement: FC = () => {
+  const { t } = useTranslation('admin');
+
+  const onSyncBtnClick = useCallback(async(e) => {
+    e.preventDefault();
+    try {
+      await apiv3Put('/external-user-groups/keycloak/sync');
+      toastSuccess(t('external_user_group.sync_succeeded'));
+    }
+    catch (errs) {
+      toastError(t(errs[0]?.message));
+    }
+  }, [t]);
 
   return (
     <>
       <KeycloakGroupSyncSettingsForm />
+      <h3 className="border-bottom mb-3">{t('external_user_group.execute_sync')}</h3>
+      <form onSubmit={onSyncBtnClick}>
+        <div className="row">
+          <div className="col-md-3"></div>
+          <div className="col-md-6"><button className="btn btn-primary" type="submit">{t('external_user_group.sync')}</button></div>
+        </div>
+      </form>
     </>
   );
 };

+ 1 - 1
apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupManagement.tsx

@@ -37,7 +37,7 @@ export const LdapGroupManagement: FC = () => {
       else {
         await apiv3Put('/external-user-groups/ldap/sync');
       }
-      toastSuccess(t('external_user_group.ldap.sync_succeeded'));
+      toastSuccess(t('external_user_group.sync_succeeded'));
     }
     catch (errs) {
       toastError(t(errs[0]?.message));

+ 1 - 1
apps/app/src/features/external-user-group/interfaces/external-user-group.ts

@@ -3,7 +3,7 @@ import type {
 } from '@growi/core';
 
 
-export const ExternalGroupProviderType = { ldap: 'ldap' } as const;
+export const ExternalGroupProviderType = { ldap: 'ldap', keycloak: 'keycloak' } as const;
 export type ExternalGroupProviderType = typeof ExternalGroupProviderType[keyof typeof ExternalGroupProviderType];
 
 export interface IExternalUserGroup extends Omit<IUserGroup, 'parent'> {

+ 15 - 0
apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts

@@ -17,6 +17,7 @@ import { configManager } from '~/server/service/config-manager';
 import UserGroupService from '~/server/service/user-group';
 import loggerFactory from '~/utils/logger';
 
+import KeycloakUserGroupSyncService from '../../service/keycloak-user-group-sync';
 import LdapUserGroupSyncService from '../../service/ldap-user-group-sync';
 
 const logger = loggerFactory('growi:routes:apiv3:external-user-group');
@@ -310,6 +311,20 @@ module.exports = (crowi: Crowi): Router => {
     return res.apiv3({}, 204);
   });
 
+  router.put('/keycloak/sync', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    try {
+      const keycloakUserGroupSyncService = new KeycloakUserGroupSyncService();
+      await keycloakUserGroupSyncService.auth();
+      await keycloakUserGroupSyncService.syncExternalUserGroups();
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(err.message, 500);
+    }
+
+    return res.apiv3({}, 204);
+  });
+
   return router;
 
 };

+ 113 - 0
apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.ts

@@ -0,0 +1,113 @@
+import { GroupRepresentation, KeycloakAdminClient, UserRepresentation } from '@s3pweb/keycloak-admin-client-cjs';
+
+import { configManager } from '~/server/service/config-manager';
+import { batchProcessPromiseAll } from '~/utils/promise';
+
+import { ExternalGroupProviderType, ExternalUserGroupTreeNode, ExternalUserInfo } from '../../interfaces/external-user-group';
+
+import ExternalUserGroupSyncService from './external-user-group-sync';
+
+// When d = max depth of group trees
+// Max space complexity of generateExternalUserGroupTrees will be:
+// O(TREES_BATCH_SIZE * d)
+const TREES_BATCH_SIZE = 10;
+
+class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
+
+  kcAdminClient: KeycloakAdminClient;
+
+  realm: string;
+
+  groupDescriptionAttribute: string;
+
+  constructor() {
+    const keycloakHost = configManager?.getConfig('crowi', 'external-user-group:keycloak:host');
+    const keycloakRealm = configManager?.getConfig('crowi', 'external-user-group:keycloak:realm');
+    const keycloakGroupDescriptionAttribute = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupDescriptionAttribute');
+    // TODO: allow user to choose 'oidc' or 'saml' for keycloak in settings
+    super(ExternalGroupProviderType.ldap, 'oidc');
+    this.kcAdminClient = new KeycloakAdminClient({ baseUrl: keycloakHost });
+    this.realm = keycloakRealm;
+    this.groupDescriptionAttribute = keycloakGroupDescriptionAttribute;
+  }
+
+  async auth(): Promise<void> {
+    const keycloakGroupSyncClientName = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientName');
+    const keycloakGroupSyncClientID: string = configManager.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientID');
+    const keycloakGroupSyncClientSecret: string = configManager.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientSecret');
+
+    await this.kcAdminClient.auth({
+      // grantType: 'client_credentials',
+      // clientId: keycloakGroupSyncClientID,
+      // clientSecret: keycloakGroupSyncClientSecret,
+      grantType: 'password',
+      username: 'admin',
+      password: 'admin',
+      clientId: keycloakGroupSyncClientID,
+    });
+  }
+
+  async generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]> {
+    // Type is 'GroupRepresentation', but 'find' does not return 'attributes' field. Hence, attribute for description is not present.
+    const rootGroups = await this.kcAdminClient.groups.find({ realm: this.realm });
+
+    return (await batchProcessPromiseAll(rootGroups, TREES_BATCH_SIZE, group => this.groupRepresentationToTreeNode(group)))
+      .filter((node): node is NonNullable<ExternalUserGroupTreeNode> => node != null);
+  }
+
+  private async groupRepresentationToTreeNode(group: GroupRepresentation): Promise<ExternalUserGroupTreeNode | null> {
+    if (group.id == null || group.name == null) return null;
+
+    const userRepresentations = await this.kcAdminClient.groups.listMembers({ id: group.id, realm: this.realm });
+
+    const userInfos = userRepresentations != null ? this.userRepresentationsToExternalUserInfos(userRepresentations) : [];
+    const description = await this.getGroupDescription(group.id) || undefined;
+    const childGroups = group.subGroups;
+
+    const childGroupNodesWithNull: (ExternalUserGroupTreeNode | null)[] = [];
+    if (childGroups != null) {
+      // Do not use Promise.all, because the number of promises processed can
+      // exponentially grow when group tree is enormous
+      for await (const childGroup of childGroups) {
+        childGroupNodesWithNull.push(await this.groupRepresentationToTreeNode(childGroup));
+      }
+    }
+    const childGroupNodes: ExternalUserGroupTreeNode[] = childGroupNodesWithNull
+      .filter((node): node is NonNullable<ExternalUserGroupTreeNode> => node != null);
+
+    return {
+      id: group.id,
+      userInfos,
+      childGroupNodes,
+      name: group.name,
+      description,
+    };
+  }
+
+  private async getGroupDescription(groupId: string): Promise<string | null> {
+    if (this.groupDescriptionAttribute == null) return null;
+
+    const groupDetail = await this.kcAdminClient.groups.findOne({ id: groupId, realm: this.realm });
+
+    const description = groupDetail?.attributes?.[this.groupDescriptionAttribute];
+    return typeof description === 'string' ? description : null;
+  }
+
+  private userRepresentationsToExternalUserInfos(userRepresentations: UserRepresentation[]): ExternalUserInfo[] {
+    const externalUserGroupsWithNull: (ExternalUserInfo | null)[] = userRepresentations.map((userRepresentation) => {
+      if (userRepresentation.id != null && userRepresentation.username != null) {
+        return {
+          id: userRepresentation.id,
+          username: userRepresentation.username,
+          email: userRepresentation.email,
+        };
+      }
+      return null;
+    });
+
+    return externalUserGroupsWithNull.filter((node): node is NonNullable<ExternalUserInfo> => node != null);
+  }
+
+}
+
+export default KeycloakUserGroupSyncService;

+ 5 - 3
apps/app/src/features/external-user-group/server/service/ldap-user-group-sync.ts

@@ -60,6 +60,9 @@ class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
     };
 
     const convert = async(entry: SearchResultEntry, converted: string[]): Promise<ExternalUserGroupTreeNode | null> => {
+      const name = this.ldapService.getStringValFromSearchResultEntry(entry, groupNameAttribute);
+      if (name == null) return null;
+
       if (converted.includes(entry.objectName)) {
         throw Error('external_user_group.ldap.circular_reference');
       }
@@ -70,7 +73,6 @@ class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
       const userInfos = (await batchProcessPromiseAll(userIds, USERS_BATCH_SIZE, (id) => {
         return this.getUserInfo(id);
       })).filter((info): info is NonNullable<ExternalUserInfo> => info != null);
-      const name = this.ldapService.getStringValFromSearchResultEntry(entry, groupNameAttribute);
       const description = this.ldapService.getStringValFromSearchResultEntry(entry, groupDescriptionAttribute);
       const childGroupDNs = getChildGroupDnsFromGroupEntry(entry);
 
@@ -84,13 +86,13 @@ class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
       const childGroupNodes: ExternalUserGroupTreeNode[] = childGroupNodesWithNull
         .filter((node): node is NonNullable<ExternalUserGroupTreeNode> => node != null);
 
-      return name != null ? {
+      return {
         id: entry.objectName,
         userInfos,
         childGroupNodes,
         name,
         description,
-      } : null;
+      };
     };
 
     // all the DNs of groups that are not a root of a tree

+ 32 - 0
yarn.lock

@@ -2965,6 +2965,16 @@
   resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.3.1.tgz#b50a781709c81e10701004214340f25475a171a0"
   integrity sha512-zMM9Ds+SawiUkakS7y94Ymqx+S0ORzpG3frZirN3l+UlXUmSUR7hF4wxCVqW+ei94JzV5kt0uXBcoOEAuiydrw==
 
+"@keycloak/keycloak-admin-client@22.0.1":
+  version "22.0.1"
+  resolved "https://registry.yarnpkg.com/@keycloak/keycloak-admin-client/-/keycloak-admin-client-22.0.1.tgz#2cb574c90d20e69a5b98fccce376291857070da6"
+  integrity sha512-/eKzNzT2hW/tRQd8/33dX1dfRU4xBsd3/30bL2OFF5+J+1UUmRYM2klYcFhdIkFX3P9/ptqH+vHpqCusdMcSCw==
+  dependencies:
+    camelize-ts "^3.0.0"
+    lodash-es "^4.17.21"
+    url-join "^5.0.0"
+    url-template "^3.1.0"
+
 "@khanacademy/simple-markdown@^0.8.6":
   version "0.8.6"
   resolved "https://registry.yarnpkg.com/@khanacademy/simple-markdown/-/simple-markdown-0.8.6.tgz#9c9aef1f5ce2ce60292d13849165965a57c26f25"
@@ -3489,6 +3499,13 @@
     colors "~1.2.1"
     string-argv "~0.3.1"
 
+"@s3pweb/keycloak-admin-client-cjs@^22.0.1":
+  version "22.0.1"
+  resolved "https://registry.yarnpkg.com/@s3pweb/keycloak-admin-client-cjs/-/keycloak-admin-client-cjs-22.0.1.tgz#65b2861a947a8fe9be34ff0b2932cb01652c05a8"
+  integrity sha512-F8zr13/rR3QcDzKEty541rXaubU6+Yn/5aMzmSy6in5TeUL3FLqF0QmuW3g1xrgABywcGopew2sEq0X3qJfRUw==
+  dependencies:
+    "@keycloak/keycloak-admin-client" "22.0.1"
+
 "@sematext/gc-stats@1.5.8":
   version "1.5.8"
   resolved "https://registry.yarnpkg.com/@sematext/gc-stats/-/gc-stats-1.5.8.tgz#73edb27bcbe0f3976041e2dc42cc86874128eeb9"
@@ -5674,6 +5691,11 @@ camelcase@^6.3.0:
   resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
   integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
 
+camelize-ts@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/camelize-ts/-/camelize-ts-3.0.0.tgz#b9a7b4ff802464dc3d6475637a64a9742ad3db09"
+  integrity sha512-cgRwKKavoDKLTjO4FQTs3dRBePZp/2Y9Xpud0FhuCOTE86M2cniKN4CCXgRnsyXNMmQMifVHcv6SPaMtTx6ofQ==
+
 can-use-dom@^0.1.0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/can-use-dom/-/can-use-dom-0.1.0.tgz#22cc4a34a0abc43950f42c6411024a3f6366b45a"
@@ -16572,6 +16594,16 @@ url-join@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/url-join/-/url-join-4.0.0.tgz#4d3340e807d3773bda9991f8305acdcc2a665d2a"
 
+url-join@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/url-join/-/url-join-5.0.0.tgz#c2f1e5cbd95fa91082a93b58a1f42fecb4bdbcf1"
+  integrity sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==
+
+url-template@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/url-template/-/url-template-3.1.0.tgz#d9be13d342ad31fcedc3c0bd21405fd141d02ff1"
+  integrity sha512-vB/eHWttzhN+NZzk9FcQB2h1cSEgb7zDYyvyxPhw02LYw7YqIzO+w1AqkcKvZ51gPH8o4+nyiWve/xuQqMdJZw==
+
 url-value-parser@2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/url-value-parser/-/url-value-parser-2.2.0.tgz#f38ae8cd24604ec69bc219d66929ddbbd93a2b32"