Browse Source

Merge branch 'feat/ldap-group-sync' into imprv/129542-129840-show-sync-status-on-ldap-group-sync

Futa Arai 2 years ago
parent
commit
b35a14accc

+ 2 - 1
apps/app/package.json

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

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

@@ -1061,8 +1061,11 @@
     "execute_sync": "Execute Sync",
     "execute_sync": "Execute Sync",
     "sync": "Sync",
     "sync": "Sync",
     "invalid_sync_settings": "Invalid sync settings",
     "invalid_sync_settings": "Invalid sync settings",
+    "update_sync_settings_failed": "Failed to update sync settings",
     "description_form_detail": "Please note that edited value will be overwritten on next sync if description mapper is set in 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",
     "only_description_edit_allowed": "Only description can be edited for external user groups",
+    "sync_succeeded": "Sync succeeded",
+    "sync_failed": "Sync failed",
     "sync_being_executed": "Cannot execute sync until current sync process finishes",
     "sync_being_executed": "Cannot execute sync until current sync process finishes",
     "ldap": {
     "ldap": {
       "group_sync_settings": "LDAP Group Sync Settings",
       "group_sync_settings": "LDAP Group Sync Settings",
@@ -1077,7 +1080,6 @@
       "preserve_deleted_ldap_groups": "Preserve Deleted LDAP Groups",
       "preserve_deleted_ldap_groups": "Preserve Deleted LDAP Groups",
       "name_mapper_detail": "Attribute to map as group name",
       "name_mapper_detail": "Attribute to map as group name",
       "updated_group_sync_settings": "Updated LDAP group sync settings",
       "updated_group_sync_settings": "Updated LDAP group sync settings",
-      "sync_succeeded": "Sync succeeded",
       "password": "Password",
       "password": "Password",
       "password_detail": "Login password is necessary because Bind type is set to User Bind",
       "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",
       "circular_reference": "Sync failed because there is a possible circular reference in your LDAP group tree structure",
@@ -1088,16 +1090,17 @@
       "group_sync_settings": "Keycloak Group Sync Settings",
       "group_sync_settings": "Keycloak Group Sync Settings",
       "host": "Host",
       "host": "Host",
       "host_detail": "Keycloak host URL",
       "host_detail": "Keycloak host URL",
-      "realm": "Realm",
-      "realm_detail": "Realm that contains the groups to sync",
-      "group_sync_client_name": "Group Sync Client Name",
-      "group_sync_client_name_detail": "Name of the client in the master realm to use for group sync",
-      "group_sync_client_id": "Group Sync Client ID",
-      "group_sync_client_id_detail": "ID of the client in the master realm to use for group sync",
-      "group_sync_client_secret": "Group Sync Client Secret",
-      "group_sync_client_secret_detail": "Secret of the client in the master realm to use for group sync",
+      "group_realm": "Group Realm",
+      "group_realm_detail": "Realm that contains the groups to sync",
+      "group_sync_client_realm": "Realm of client used to request to Admin API",
+      "group_sync_client_realm_detail": "Realm that contains the client used to authenticate to request to Keycloak admin API",
+      "group_sync_client_id": "Client ID",
+      "group_sync_client_id_detail": "Id of the client used to authenticate to request to Keycloak admin API",
+      "group_sync_client_secret": "Client Secret",
+      "group_sync_client_secret_detail": "Id of the secret used to authenticate to request to Keycloak admin API",
       "updated_group_sync_settings": "Updated Keycloak group sync settings",
       "updated_group_sync_settings": "Updated Keycloak group sync settings",
-      "preserve_deleted_keycloak_groups": "Preserve Deleted Keycloak Groups"
+      "preserve_deleted_keycloak_groups": "Preserve Deleted Keycloak Groups",
+      "auth_not_set": "Please set up and enable OIDC or SAML with Keycloak in security settings before sync"
     },
     },
     "auto_generate_user_on_sync": "Auto Generate User on Sync",
     "auto_generate_user_on_sync": "Auto Generate User on Sync",
     "description_mapper_detail": "Attribute to map as group description. Description can be edited after sync. However, when a mapper is set, the edited value can possibly be overwritten by the next sync."
     "description_mapper_detail": "Attribute to map as group description. Description can be edited after sync. However, when a mapper is set, the edited value can possibly be overwritten by the next sync."

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

@@ -1070,9 +1070,12 @@
     "execute_sync": "同期実行",
     "execute_sync": "同期実行",
     "sync": "同期",
     "sync": "同期",
     "invalid_sync_settings": "同期設定に誤りがあります",
     "invalid_sync_settings": "同期設定に誤りがあります",
+    "update_sync_settings_failed": "同期設定の更新が失敗しました",
     "description_form_detail": "同期設定で「説明」の mapper が設定されている場合、編集内容は再同期によって上書きされることに注意してください",
     "description_form_detail": "同期設定で「説明」の mapper が設定されている場合、編集内容は再同期によって上書きされることに注意してください",
     "only_description_edit_allowed": "外部グループは説明の編集のみが可能です",
     "only_description_edit_allowed": "外部グループは説明の編集のみが可能です",
     "sync_being_executed": "現在実行されている同期が終わるまで次の実行ができません",
     "sync_being_executed": "現在実行されている同期が終わるまで次の実行ができません",
+    "sync_succeeded": "同期に成功しました",
+    "sync_failed": "同期に失敗しました",
     "ldap": {
     "ldap": {
       "group_sync_settings": "LDAP グループ同期設定",
       "group_sync_settings": "LDAP グループ同期設定",
       "group_search_base_DN": "グループ検索ベース DN",
       "group_search_base_DN": "グループ検索ベース DN",
@@ -1086,7 +1089,6 @@
       "preserve_deleted_ldap_groups": "LDAP から削除されたグループを GROWI に残す",
       "preserve_deleted_ldap_groups": "LDAP から削除されたグループを GROWI に残す",
       "name_mapper_detail": "グループの「名前」として読み込む属性",
       "name_mapper_detail": "グループの「名前」として読み込む属性",
       "updated_group_sync_settings": "LDAP グループ同期設定を更新しました",
       "updated_group_sync_settings": "LDAP グループ同期設定を更新しました",
-      "sync_succeeded": "同期に成功しました",
       "password": "パスワード",
       "password": "パスワード",
       "password_detail": "認証設定がユーザ Bind のため、ログイン時のパスワードの入力が必要となります",
       "password_detail": "認証設定がユーザ Bind のため、ログイン時のパスワードの入力が必要となります",
       "circular_reference": "LDAP グループの木構造に循環参照が行われている可能性があるため、同期に失敗しました",
       "circular_reference": "LDAP グループの木構造に循環参照が行われている可能性があるため、同期に失敗しました",
@@ -1097,16 +1099,17 @@
       "group_sync_settings": "Keycloak グループ同期設定",
       "group_sync_settings": "Keycloak グループ同期設定",
       "host": "Host",
       "host": "Host",
       "host_detail": "Keycloak ホスト URL",
       "host_detail": "Keycloak ホスト URL",
-      "realm": "Realm",
-      "realm_detail": "同期対象のグループがある realm",
-      "group_sync_client_name": "グループ同期に使う Client 名",
-      "group_sync_client_name_detail": "Master realm にある、グループ同期に使う Client 名",
-      "group_sync_client_id": "グループ同期に使う Client の ID",
-      "group_sync_client_id_detail": "Master realm にある、グループ同期に使う Client ID",
-      "group_sync_client_secret": "グループ同期に使う Client の Secret",
-      "group_sync_client_secret_detail": "Master realm にある、グループ同期に使う Client の secret",
+      "group_realm": "Group Realm",
+      "group_realm_detail": "同期対象のグループがある realm",
+      "group_sync_client_realm": "Admin API にリクエストするための client がある realm",
+      "group_sync_client_realm_detail": "Keycloak admin API にリクエストするための認証に使う client がある realm",
+      "group_sync_client_id": "Client の ID",
+      "group_sync_client_id_detail": "Keycloak admin API にリクエストするための認証に使う client の Client ID",
+      "group_sync_client_secret": "Client の Secret",
+      "group_sync_client_secret_detail": "Keycloak admin API にリクエストするための認証に使う client の secret",
       "updated_group_sync_settings": "Keycloak グループ同期設定を更新しました",
       "updated_group_sync_settings": "Keycloak グループ同期設定を更新しました",
-      "preserve_deleted_keycloak_groups": "Keycloak から削除されたグループを GROWI に残す"
+      "preserve_deleted_keycloak_groups": "Keycloak から削除されたグループを GROWI に残す",
+      "auth_not_set": "同期実行前に、セキュリティ設定で Keycloak を使った OIDC または SAML 認証を設定し、有効にしてください"
     },
     },
     "auto_generate_user_on_sync": "作成されていない GROWI アカウントを自動生成する",
     "auto_generate_user_on_sync": "作成されていない GROWI アカウントを自動生成する",
     "description_mapper_detail": "グループの「説明」として読み込む属性。「説明」は同期後に編集可能です。ただし、mapper が設定されている場合、編集内容は再同期によって上書きされます。"
     "description_mapper_detail": "グループの「説明」として読み込む属性。「説明」は同期後に編集可能です。ただし、mapper が設定されている場合、編集内容は再同期によって上書きされます。"

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

@@ -1069,8 +1069,11 @@
     "execute_sync": "Execute Sync",
     "execute_sync": "Execute Sync",
     "sync": "Sync",
     "sync": "Sync",
     "invalid_sync_settings": "Invalid sync settings",
     "invalid_sync_settings": "Invalid sync settings",
+    "update_sync_settings_failed": "Failed to update sync settings",
     "description_form_detail": "Please note that edited value will be overwritten on next sync if description mapper is set in 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",
     "only_description_edit_allowed": "Only description can be edited for external user groups",
+    "sync_succeeded": "Sync succeeded",
+    "sync_failed": "Sync failed",
     "sync_being_executed": "Cannot execute sync until current sync process finishes",
     "sync_being_executed": "Cannot execute sync until current sync process finishes",
     "ldap": {
     "ldap": {
       "group_sync_settings": "LDAP Group Sync Settings",
       "group_sync_settings": "LDAP Group Sync Settings",
@@ -1085,7 +1088,6 @@
       "preserve_deleted_ldap_groups": "Preserve Deleted LDAP Groups",
       "preserve_deleted_ldap_groups": "Preserve Deleted LDAP Groups",
       "name_mapper_detail": "Attribute to map as group name",
       "name_mapper_detail": "Attribute to map as group name",
       "updated_group_sync_settings": "Updated LDAP group sync settings",
       "updated_group_sync_settings": "Updated LDAP group sync settings",
-      "sync_succeeded": "Sync succeeded",
       "password": "Password",
       "password": "Password",
       "password_detail": "Login password is necessary because Bind type is set to User Bind",
       "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",
       "circular_reference": "Sync failed because there is a possible circular reference in your LDAP group tree structure",
@@ -1096,16 +1098,17 @@
       "group_sync_settings": "Keycloak Group Sync Settings",
       "group_sync_settings": "Keycloak Group Sync Settings",
       "host": "Host",
       "host": "Host",
       "host_detail": "Keycloak host URL",
       "host_detail": "Keycloak host URL",
-      "realm": "Realm",
-      "realm_detail": "Realm that contains the groups to sync",
-      "group_sync_client_name": "Group Sync Client Name",
-      "group_sync_client_name_detail": "Name of the client in the master realm to use for group sync",
-      "group_sync_client_id": "Group Sync Client ID",
-      "group_sync_client_id_detail": "ID of the client in the master realm to use for group sync",
-      "group_sync_client_secret": "Group Sync Client Secret",
-      "group_sync_client_secret_detail": "Secret of the client in the master realm to use for group sync",
+      "group_realm": "Group Realm",
+      "group_realm_detail": "Realm that contains the groups to sync",
+      "group_sync_client_realm": "Realm of client used to request to Admin API",
+      "group_sync_client_realm_detail": "Realm that contains the client used to authenticate to request to Keycloak admin API",
+      "group_sync_client_id": "Client ID",
+      "group_sync_client_id_detail": "Id of the client used to authenticate to request to Keycloak admin API",
+      "group_sync_client_secret": "Client Secret",
+      "group_sync_client_secret_detail": "Id of the secret used to authenticate to request to Keycloak admin API",
       "updated_group_sync_settings": "Updated Keycloak group sync settings",
       "updated_group_sync_settings": "Updated Keycloak group sync settings",
-      "preserve_deleted_keycloak_groups": "Preserve Deleted Keycloak Groups"
+      "preserve_deleted_keycloak_groups": "Preserve Deleted Keycloak Groups",
+      "auth_not_set": "Please set up and enable OIDC or SAML with Keycloak in security settings before sync"
     },
     },
     "auto_generate_user_on_sync": "Auto Generate User on Sync",
     "auto_generate_user_on_sync": "Auto Generate User on Sync",
     "description_mapper_detail": "Attribute to map as group description. Description can be edited after sync. However, when a mapper is set, the edited value can possibly be overwritten by the next sync."
     "description_mapper_detail": "Attribute to map as group description. Description can be edited after sync. However, when a mapper is set, the edited value can possibly be overwritten by the next sync."

+ 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';
 import { KeycloakGroupSyncSettingsForm } from './KeycloakGroupSyncSettingsForm';
 
 
 export const KeycloakGroupManagement: FC = () => {
 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]?.code));
+    }
+  }, [t]);
 
 
   return (
   return (
     <>
     <>
       <KeycloakGroupSyncSettingsForm />
       <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>
     </>
     </>
   );
   );
 };
 };

+ 16 - 16
apps/app/src/features/external-user-group/client/components/ExternalUserGroup/KeycloakGroupSyncSettingsForm.tsx

@@ -16,8 +16,8 @@ export const KeycloakGroupSyncSettingsForm: FC = () => {
 
 
   const [formValues, setFormValues] = useState<KeycloakGroupSyncSettings>({
   const [formValues, setFormValues] = useState<KeycloakGroupSyncSettings>({
     keycloakHost: '',
     keycloakHost: '',
-    keycloakRealm: '',
-    keycloakGroupSyncClientName: '',
+    keycloakGroupRealm: '',
+    keycloakGroupSyncClientRealm: '',
     keycloakGroupSyncClientID: '',
     keycloakGroupSyncClientID: '',
     keycloakGroupSyncClientSecret: '',
     keycloakGroupSyncClientSecret: '',
     autoGenerateUserOnKeycloakGroupSync: false,
     autoGenerateUserOnKeycloakGroupSync: false,
@@ -68,43 +68,43 @@ export const KeycloakGroupSyncSettingsForm: FC = () => {
           </div>
           </div>
         </div>
         </div>
         <div className="row form-group">
         <div className="row form-group">
-          <label htmlFor="keycloakRealm" className="text-left text-md-right col-md-3 col-form-label">
-            {t('external_user_group.keycloak.realm')}
+          <label htmlFor="keycloakGroupRealm" className="text-left text-md-right col-md-3 col-form-label">
+            {t('external_user_group.keycloak.group_realm')}
           </label>
           </label>
           <div className="col-md-6">
           <div className="col-md-6">
             <input
             <input
               className="form-control"
               className="form-control"
               required
               required
               type="text"
               type="text"
-              name="keycloakRealm"
-              id="keycloakRealm"
-              value={formValues.keycloakRealm}
-              onChange={e => setFormValues({ ...formValues, keycloakRealm: e.target.value })}
+              name="keycloakGroupRealm"
+              id="keycloakGroupRealm"
+              value={formValues.keycloakGroupRealm}
+              onChange={e => setFormValues({ ...formValues, keycloakGroupRealm: e.target.value })}
             />
             />
             <p className="form-text text-muted">
             <p className="form-text text-muted">
               <small>
               <small>
-                {t('external_user_group.keycloak.realm_detail')} <br />
+                {t('external_user_group.keycloak.group_realm_detail')} <br />
               </small>
               </small>
             </p>
             </p>
           </div>
           </div>
         </div>
         </div>
         <div className="row form-group">
         <div className="row form-group">
-          <label htmlFor="keycloakGroupSyncClientName" className="text-left text-md-right col-md-3 col-form-label">
-            {t('external_user_group.keycloak.group_sync_client_name')}
+          <label htmlFor="keycloakGroupSyncClientRealm" className="text-left text-md-right col-md-3 col-form-label">
+            {t('external_user_group.keycloak.group_sync_client_realm')}
           </label>
           </label>
           <div className="col-md-6">
           <div className="col-md-6">
             <input
             <input
               className="form-control"
               className="form-control"
               required
               required
               type="text"
               type="text"
-              name="keycloakGroupSyncClientName"
-              id="keycloakGroupSyncClientName"
-              value={formValues.keycloakGroupSyncClientName}
-              onChange={e => setFormValues({ ...formValues, keycloakGroupSyncClientName: e.target.value })}
+              name="keycloakGroupSyncClientRealm"
+              id="keycloakGroupSyncClientRealm"
+              value={formValues.keycloakGroupSyncClientRealm}
+              onChange={e => setFormValues({ ...formValues, keycloakGroupSyncClientRealm: e.target.value })}
             />
             />
             <p className="form-text text-muted">
             <p className="form-text text-muted">
               <small>
               <small>
-                {t('external_user_group.keycloak.group_sync_client_name_detail')} <br />
+                {t('external_user_group.keycloak.group_sync_client_realm_detail')} <br />
               </small>
               </small>
             </p>
             </p>
           </div>
           </div>

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

@@ -68,7 +68,7 @@ export const LdapGroupManagement: FC = () => {
       else {
       else {
         await apiv3Put('/external-user-groups/ldap/sync');
         await apiv3Put('/external-user-groups/ldap/sync');
       }
       }
-      toastSuccess(t('external_user_group.ldap.sync_succeeded'));
+      toastSuccess(t('external_user_group.sync_succeeded'));
       mutateExternalUserGroups();
       mutateExternalUserGroups();
     }
     }
     catch (errs) {
     catch (errs) {

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

@@ -38,7 +38,7 @@ export const LdapGroupSyncSettingsForm: FC = () => {
       toastSuccess(t('external_user_group.ldap.updated_group_sync_settings'));
       toastSuccess(t('external_user_group.ldap.updated_group_sync_settings'));
     }
     }
     catch (errs) {
     catch (errs) {
-      toastError(t(errs[0]?.message));
+      toastError(t(errs[0]?.code));
     }
     }
   }, [formValues, t]);
   }, [formValues, t]);
 
 

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

@@ -3,7 +3,7 @@ import type {
 } from '@growi/core';
 } 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 type ExternalGroupProviderType = typeof ExternalGroupProviderType[keyof typeof ExternalGroupProviderType];
 
 
 export interface IExternalUserGroup extends Omit<IUserGroup, 'parent'> {
 export interface IExternalUserGroup extends Omit<IUserGroup, 'parent'> {
@@ -36,8 +36,8 @@ export interface LdapGroupSyncSettings {
 
 
 export interface KeycloakGroupSyncSettings {
 export interface KeycloakGroupSyncSettings {
   keycloakHost: string
   keycloakHost: string
-  keycloakRealm: string
-  keycloakGroupSyncClientName: string
+  keycloakGroupRealm: string
+  keycloakGroupSyncClientRealm: string
   keycloakGroupSyncClientID: string
   keycloakGroupSyncClientID: string
   keycloakGroupSyncClientSecret: string
   keycloakGroupSyncClientSecret: string
   autoGenerateUserOnKeycloakGroupSync: boolean
   autoGenerateUserOnKeycloakGroupSync: boolean

+ 62 - 10
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 UserGroupService from '~/server/service/user-group';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import KeycloakUserGroupSyncService from '../../service/keycloak-user-group-sync';
 import { ldapUserGroupSyncService } from '../../service/ldap-user-group-sync';
 import { ldapUserGroupSyncService } from '../../service/ldap-user-group-sync';
 
 
 const logger = loggerFactory('growi:routes:apiv3:external-user-group');
 const logger = loggerFactory('growi:routes:apiv3:external-user-group');
@@ -47,8 +48,8 @@ module.exports = (crowi: Crowi): Router => {
     ],
     ],
     keycloakSyncSettings: [
     keycloakSyncSettings: [
       body('keycloakHost').exists({ checkFalsy: true }).isString(),
       body('keycloakHost').exists({ checkFalsy: true }).isString(),
-      body('keycloakRealm').exists({ checkFalsy: true }).isString(),
-      body('keycloakGroupSyncClientName').exists({ checkFalsy: true }).isString(),
+      body('keycloakGroupRealm').exists({ checkFalsy: true }).isString(),
+      body('keycloakGroupSyncClientRealm').exists({ checkFalsy: true }).isString(),
       body('keycloakGroupSyncClientID').exists({ checkFalsy: true }).isString(),
       body('keycloakGroupSyncClientID').exists({ checkFalsy: true }).isString(),
       body('keycloakGroupSyncClientSecret').exists({ checkFalsy: true }).isString(),
       body('keycloakGroupSyncClientSecret').exists({ checkFalsy: true }).isString(),
       body('autoGenerateUserOnKeycloakGroupSync').exists().isBoolean(),
       body('autoGenerateUserOnKeycloakGroupSync').exists().isBoolean(),
@@ -225,8 +226,8 @@ module.exports = (crowi: Crowi): Router => {
   router.get('/keycloak/sync-settings', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
   router.get('/keycloak/sync-settings', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
     const settings = {
     const settings = {
       keycloakHost: configManager?.getConfig('crowi', 'external-user-group:keycloak:host'),
       keycloakHost: configManager?.getConfig('crowi', 'external-user-group:keycloak:host'),
-      keycloakRealm: configManager?.getConfig('crowi', 'external-user-group:keycloak:realm'),
-      keycloakGroupSyncClientName: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientName'),
+      keycloakGroupRealm: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupRealm'),
+      keycloakGroupSyncClientRealm: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientRealm'),
       keycloakGroupSyncClientID: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientID'),
       keycloakGroupSyncClientID: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientID'),
       keycloakGroupSyncClientSecret: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientSecret'),
       keycloakGroupSyncClientSecret: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientSecret'),
       autoGenerateUserOnKeycloakGroupSync: configManager?.getConfig('crowi', 'external-user-group:keycloak:autoGenerateUserOnGroupSync'),
       autoGenerateUserOnKeycloakGroupSync: configManager?.getConfig('crowi', 'external-user-group:keycloak:autoGenerateUserOnGroupSync'),
@@ -240,7 +241,9 @@ module.exports = (crowi: Crowi): Router => {
   router.put('/ldap/sync-settings', loginRequiredStrictly, adminRequired, validators.ldapSyncSettings, async(req: AuthorizedRequest, res: ApiV3Response) => {
   router.put('/ldap/sync-settings', loginRequiredStrictly, adminRequired, validators.ldapSyncSettings, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const errors = validationResult(req);
     const errors = validationResult(req);
     if (!errors.isEmpty()) {
     if (!errors.isEmpty()) {
-      return res.apiv3Err('external_user_group.invalid_sync_settings', 400);
+      return res.apiv3Err(
+        new ErrorV3('Invalid sync settings', 'external_user_group.invalid_sync_settings'), 400,
+      );
     }
     }
 
 
     const params = {
     const params = {
@@ -265,7 +268,9 @@ module.exports = (crowi: Crowi): Router => {
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);
-      return res.apiv3Err(err, 500);
+      return res.apiv3Err(
+        new ErrorV3('Sync settings update failed', 'external_user_group.update_sync_settings_failed'), 500,
+      );
     }
     }
   });
   });
 
 
@@ -273,13 +278,15 @@ module.exports = (crowi: Crowi): Router => {
     async(req: AuthorizedRequest, res: ApiV3Response) => {
     async(req: AuthorizedRequest, res: ApiV3Response) => {
       const errors = validationResult(req);
       const errors = validationResult(req);
       if (!errors.isEmpty()) {
       if (!errors.isEmpty()) {
-        return res.apiv3Err('external_user_group.invalid_sync_settings', 400);
+        return res.apiv3Err(
+          new ErrorV3('Invalid sync settings', 'external_user_group.invalid_sync_settings'), 400,
+        );
       }
       }
 
 
       const params = {
       const params = {
         'external-user-group:keycloak:host': req.body.keycloakHost,
         'external-user-group:keycloak:host': req.body.keycloakHost,
-        'external-user-group:keycloak:realm': req.body.keycloakRealm,
-        'external-user-group:keycloak:groupSyncClientName': req.body.keycloakGroupSyncClientName,
+        'external-user-group:keycloak:groupRealm': req.body.keycloakGroupRealm,
+        'external-user-group:keycloak:groupSyncClientRealm': req.body.keycloakGroupSyncClientRealm,
         'external-user-group:keycloak:groupSyncClientID': req.body.keycloakGroupSyncClientID,
         'external-user-group:keycloak:groupSyncClientID': req.body.keycloakGroupSyncClientID,
         'external-user-group:keycloak:groupSyncClientSecret': req.body.keycloakGroupSyncClientSecret,
         'external-user-group:keycloak:groupSyncClientSecret': req.body.keycloakGroupSyncClientSecret,
         'external-user-group:keycloak:autoGenerateUserOnGroupSync': req.body.autoGenerateUserOnKeycloakGroupSync,
         'external-user-group:keycloak:autoGenerateUserOnGroupSync': req.body.autoGenerateUserOnKeycloakGroupSync,
@@ -293,7 +300,9 @@ module.exports = (crowi: Crowi): Router => {
       }
       }
       catch (err) {
       catch (err) {
         logger.error(err);
         logger.error(err);
-        return res.apiv3Err(err, 500);
+        return res.apiv3Err(
+          new ErrorV3('Sync settings update failed', 'external_user_group.update_sync_settings_failed'), 500,
+        );
       }
       }
     });
     });
 
 
@@ -311,6 +320,49 @@ module.exports = (crowi: Crowi): Router => {
     return res.apiv3({}, 204);
     return res.apiv3({}, 204);
   });
   });
 
 
+  router.put('/keycloak/sync', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    const getAuthProviderType = () => {
+      const kcHost = configManager?.getConfig('crowi', 'external-user-group:keycloak:host');
+      const kcGroupRealm = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupRealm');
+
+      // starts with kcHost, contains kcGroupRealm in path
+      // see: https://regex101.com/r/3ihDmf/1
+      const regex = new RegExp(`^${kcHost}/.*/${kcGroupRealm}(/|$).*`);
+
+      const isOidcEnabled = configManager.getConfig('crowi', 'security:passport-oidc:isEnabled');
+      const oidcIssuerHost = configManager.getConfig('crowi', 'security:passport-oidc:issuerHost');
+
+      if (isOidcEnabled && regex.test(oidcIssuerHost)) return 'oidc';
+
+      const isSamlEnabled = configManager.getConfig('crowi', 'security:passport-saml:isEnabled');
+      const samlEntryPoint = configManager.getConfig('crowi', 'security:passport-saml:entryPoint');
+
+      if (isSamlEnabled && regex.test(samlEntryPoint)) return 'saml';
+
+      return null;
+    };
+
+    const authProviderType = getAuthProviderType();
+    if (authProviderType == null) {
+      return res.apiv3Err(
+        new ErrorV3('Authentication using keycloak is not set', 'external_user_group.keycloak.auth_not_set'), 500,
+      );
+    }
+
+    try {
+      const keycloakUserGroupSyncService = new KeycloakUserGroupSyncService(authProviderType);
+      await keycloakUserGroupSyncService.syncExternalUserGroups();
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(
+        new ErrorV3('Sync failed', 'external_user_group.sync_failed'), 500,
+      );
+    }
+
+    return res.apiv3({}, 204);
+  });
+
   return router;
   return router;
 
 
 };
 };

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

@@ -0,0 +1,123 @@
+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; // realm that contains the groups
+
+  groupDescriptionAttribute: string; // attribute to map to group description
+
+  constructor(authProviderType: string) {
+    const kcHost = configManager?.getConfig('crowi', 'external-user-group:keycloak:host');
+    const kcGroupRealm = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupRealm');
+    const kcGroupSyncClientRealm = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientRealm');
+    const kcGroupDescriptionAttribute = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupDescriptionAttribute');
+
+    super(ExternalGroupProviderType.keycloak, authProviderType);
+    this.kcAdminClient = new KeycloakAdminClient({ baseUrl: kcHost, realmName: kcGroupSyncClientRealm });
+    this.realm = kcGroupRealm;
+    this.groupDescriptionAttribute = kcGroupDescriptionAttribute;
+  }
+
+  async generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]> {
+    await this.auth();
+
+    // 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);
+  }
+
+  /**
+   * Authenticate to group sync client using client credentials grant type
+   */
+  private async auth(): Promise<void> {
+    const kcGroupSyncClientID: string = configManager.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientID');
+    const kcGroupSyncClientSecret: string = configManager.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientSecret');
+
+    await this.kcAdminClient.auth({
+      grantType: 'client_credentials',
+      clientId: kcGroupSyncClientID,
+      clientSecret: kcGroupSyncClientSecret,
+    });
+  }
+
+  /**
+   * Convert GroupRepresentation response returned from Keycloak to ExternalUserGroupTreeNode
+   */
+  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,
+    };
+  }
+
+  /**
+   * Fetch group detail from Keycloak and return group 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]?.[0];
+    return typeof description === 'string' ? description : null;
+  }
+
+  /**
+   * Convert UserRepresentation array response returned from Keycloak to ExternalUserInfo
+   */
+  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

@@ -62,6 +62,9 @@ class LdapUserGroupSyncService extends ExternalUserGroupSyncService<SyncParamsTy
     };
     };
 
 
     const convert = async(entry: SearchResultEntry, converted: string[]): Promise<ExternalUserGroupTreeNode | null> => {
     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)) {
       if (converted.includes(entry.objectName)) {
         throw Error('external_user_group.ldap.circular_reference');
         throw Error('external_user_group.ldap.circular_reference');
       }
       }
@@ -72,7 +75,6 @@ class LdapUserGroupSyncService extends ExternalUserGroupSyncService<SyncParamsTy
       const userInfos = (await batchProcessPromiseAll(userIds, USERS_BATCH_SIZE, (id) => {
       const userInfos = (await batchProcessPromiseAll(userIds, USERS_BATCH_SIZE, (id) => {
         return this.getUserInfo(id);
         return this.getUserInfo(id);
       })).filter((info): info is NonNullable<ExternalUserInfo> => info != null);
       })).filter((info): info is NonNullable<ExternalUserInfo> => info != null);
-      const name = this.ldapService.getStringValFromSearchResultEntry(entry, groupNameAttribute);
       const description = this.ldapService.getStringValFromSearchResultEntry(entry, groupDescriptionAttribute);
       const description = this.ldapService.getStringValFromSearchResultEntry(entry, groupDescriptionAttribute);
       const childGroupDNs = getChildGroupDnsFromGroupEntry(entry);
       const childGroupDNs = getChildGroupDnsFromGroupEntry(entry);
 
 
@@ -86,13 +88,13 @@ class LdapUserGroupSyncService extends ExternalUserGroupSyncService<SyncParamsTy
       const childGroupNodes: ExternalUserGroupTreeNode[] = childGroupNodesWithNull
       const childGroupNodes: ExternalUserGroupTreeNode[] = childGroupNodesWithNull
         .filter((node): node is NonNullable<ExternalUserGroupTreeNode> => node != null);
         .filter((node): node is NonNullable<ExternalUserGroupTreeNode> => node != null);
 
 
-      return name != null ? {
+      return {
         id: entry.objectName,
         id: entry.objectName,
         userInfos,
         userInfos,
         childGroupNodes,
         childGroupNodes,
         name,
         name,
         description,
         description,
-      } : null;
+      };
     };
     };
 
 
     // all the DNs of groups that are not a root of a tree
     // 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"
   resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.3.1.tgz#b50a781709c81e10701004214340f25475a171a0"
   integrity sha512-zMM9Ds+SawiUkakS7y94Ymqx+S0ORzpG3frZirN3l+UlXUmSUR7hF4wxCVqW+ei94JzV5kt0uXBcoOEAuiydrw==
   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":
 "@khanacademy/simple-markdown@^0.8.6":
   version "0.8.6"
   version "0.8.6"
   resolved "https://registry.yarnpkg.com/@khanacademy/simple-markdown/-/simple-markdown-0.8.6.tgz#9c9aef1f5ce2ce60292d13849165965a57c26f25"
   resolved "https://registry.yarnpkg.com/@khanacademy/simple-markdown/-/simple-markdown-0.8.6.tgz#9c9aef1f5ce2ce60292d13849165965a57c26f25"
@@ -3489,6 +3499,13 @@
     colors "~1.2.1"
     colors "~1.2.1"
     string-argv "~0.3.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":
 "@sematext/gc-stats@1.5.8":
   version "1.5.8"
   version "1.5.8"
   resolved "https://registry.yarnpkg.com/@sematext/gc-stats/-/gc-stats-1.5.8.tgz#73edb27bcbe0f3976041e2dc42cc86874128eeb9"
   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"
   resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
   integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
   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:
 can-use-dom@^0.1.0:
   version "0.1.0"
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/can-use-dom/-/can-use-dom-0.1.0.tgz#22cc4a34a0abc43950f42c6411024a3f6366b45a"
   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"
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/url-join/-/url-join-4.0.0.tgz#4d3340e807d3773bda9991f8305acdcc2a665d2a"
   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:
 url-value-parser@2.2.0:
   version "2.2.0"
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/url-value-parser/-/url-value-parser-2.2.0.tgz#f38ae8cd24604ec69bc219d66929ddbbd93a2b32"
   resolved "https://registry.yarnpkg.com/url-value-parser/-/url-value-parser-2.2.0.tgz#f38ae8cd24604ec69bc219d66929ddbbd93a2b32"