Explorar o código

Merge pull request #8054 from weseek/feat/123278-129841-keycloak-group-sync-config

Feat/123278 129841 keycloak group sync config
Ryoji Shimizu %!s(int64=2) %!d(string=hai) anos
pai
achega
2936a60389

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

@@ -1069,10 +1069,8 @@
       "membership_attribute_type_detail": "Whether membership attribute value is of type DN or UID",
       "child_group_attribute": "Child Group Attribute",
       "child_group_attribute_detail": "Attribute of the group object which indicates child group info. The attribute value needs to be the DN of the child group.",
-      "auto_generate_user_on_sync": "Auto Generate User on Sync",
       "preserve_deleted_ldap_groups": "Preserve Deleted LDAP Groups",
       "name_mapper_detail": "Attribute to map as group name",
-      "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.",
       "updated_group_sync_settings": "Updated LDAP group sync settings",
       "sync_succeeded": "Sync succeeded",
       "password": "Password",
@@ -1080,7 +1078,24 @@
       "circular_reference": "Sync failed because there is a possible circular reference in your LDAP group tree structure",
       "group_search_failed": "LDAP group search failed. Please check your LDAP security settings and group sync settings.",
       "user_search_failed": "LDAP user search failed. Please check your LDAP security settings and group sync settings."
-    }
+    },
+    "keycloak": {
+      "group_sync_settings": "Keycloak Group Sync Settings",
+      "host": "Host",
+      "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",
+      "updated_group_sync_settings": "Updated Keycloak group sync settings",
+      "preserve_deleted_keycloak_groups": "Preserve Deleted Keycloak Groups"
+    },
+    "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."
   },
   "toaster": {
     "grant_user_admin": "Succeeded to grant {{username}} admin",

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

@@ -1077,10 +1077,8 @@
       "membership_attribute_type_detail": "グループの所属メンバーを表すグループオブジェクトの属性値は DN か UID か",
       "child_group_attribute": "子グループを表す LDAP 属性",
       "child_group_attribute_detail": "グループに所属する子グループを表すグループオブジェクトの属性。属性値は DN である必要があります。",
-      "auto_generate_user_on_sync": "作成されていない GROWI アカウントを自動生成する",
       "preserve_deleted_ldap_groups": "LDAP から削除されたグループを GROWI に残す",
       "name_mapper_detail": "グループの「名前」として読み込む属性",
-      "description_mapper_detail": "グループの「説明」として読み込む属性。「説明」は同期後に編集可能です。ただし、mapper が設定されている場合、編集内容は再同期によって上書きされます。",
       "updated_group_sync_settings": "LDAP グループ同期設定を更新しました",
       "sync_succeeded": "同期に成功しました",
       "password": "パスワード",
@@ -1088,7 +1086,24 @@
       "circular_reference": "LDAP グループの木構造に循環参照が行われている可能性があるため、同期に失敗しました",
       "group_search_failed": "LDAP グループ検索に失敗しました。LDAP セキュリティ設定、グループ同期設定が正しいことを確認してください。",
       "user_search_failed": "LDAP ユーザ検索に失敗しました。LDAP セキュリティ設定、グループ同期設定が正しいことを確認してください。"
-    }
+    },
+    "keycloak": {
+      "group_sync_settings": "Keycloak グループ同期設定",
+      "host": "Host",
+      "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",
+      "updated_group_sync_settings": "Keycloak グループ同期設定を更新しました",
+      "preserve_deleted_keycloak_groups": "Keycloak から削除されたグループを GROWI に残す"
+    },
+    "auto_generate_user_on_sync": "作成されていない GROWI アカウントを自動生成する",
+    "description_mapper_detail": "グループの「説明」として読み込む属性。「説明」は同期後に編集可能です。ただし、mapper が設定されている場合、編集内容は再同期によって上書きされます。"
   },
   "toaster": {
     "grant_user_admin": "{{username}}を管理者に設定しました",

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

@@ -1077,19 +1077,33 @@
       "membership_attribute_type_detail": "Whether membership attribute value is of type DN or UID",
       "child_group_attribute": "Child Group Attribute",
       "child_group_attribute_detail": "Attribute of the group object which indicates child group info. The attribute value needs to be the DN of the child group.",
-      "auto_generate_user_on_sync": "Auto Generate User on Sync",
       "preserve_deleted_ldap_groups": "Preserve Deleted LDAP Groups",
       "name_mapper_detail": "Attribute to map as group name",
-      "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.",
       "updated_group_sync_settings": "Updated LDAP group sync settings",
-      "sync_succeeded": "Group sync succeeded",
-      "sync_failed": "Sync failed. Check LDAP security settings and 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",
       "group_search_failed": "LDAP group search failed. Please check your LDAP security settings and group sync settings.",
       "user_search_failed": "LDAP user search failed. Please check your LDAP security settings and group sync settings."
-    }
+    },
+    "keycloak": {
+      "group_sync_settings": "Keycloak Group Sync Settings",
+      "host": "Host",
+      "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",
+      "updated_group_sync_settings": "Updated Keycloak group sync settings",
+      "preserve_deleted_keycloak_groups": "Preserve Deleted Keycloak Groups"
+    },
+    "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."
   },
   "toaster": {
     "grant_user_admin": "Succeeded to grant {{username}} admin",

+ 8 - 0
apps/app/src/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement.tsx

@@ -16,6 +16,7 @@ import { useIsAclEnabled } from '~/stores/context';
 
 import { useSWRxChildExternalUserGroupList, useSWRxExternalUserGroupList, useSWRxExternalUserGroupRelationList } from '../../stores/external-user-group';
 
+import { KeycloakGroupManagement } from './KeycloakGroupManagement';
 import { LdapGroupManagement } from './LdapGroupManagement';
 
 export const ExternalGroupManagement: FC = () => {
@@ -122,6 +123,10 @@ export const ExternalGroupManagement: FC = () => {
         Icon: () => <i className="fa fa-sitemap" />,
         i18n: 'LDAP',
       },
+      keycloak: {
+        Icon: () => <i className="fa fa-key" />,
+        i18n: 'Keycloak',
+      },
     };
   }, []);
 
@@ -167,6 +172,9 @@ export const ExternalGroupManagement: FC = () => {
         <TabPane tabId="ldap">
           {activeComponents.has('ldap') && <LdapGroupManagement />}
         </TabPane>
+        <TabPane tabId="keycloak">
+          {activeComponents.has('keycloak') && <KeycloakGroupManagement />}
+        </TabPane>
       </TabContent>
     </>
   );

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

@@ -0,0 +1,12 @@
+import { FC } from 'react';
+
+import { KeycloakGroupSyncSettingsForm } from './KeycloakGroupSyncSettingsForm';
+
+export const KeycloakGroupManagement: FC = () => {
+
+  return (
+    <>
+      <KeycloakGroupSyncSettingsForm />
+    </>
+  );
+};

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

@@ -0,0 +1,241 @@
+import {
+  FC, useCallback, useEffect, useState,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import { useSWRxKeycloakGroupSyncSettings } from '~/features/external-user-group/client/stores/external-user-group';
+import { KeycloakGroupSyncSettings } from '~/features/external-user-group/interfaces/external-user-group';
+
+export const KeycloakGroupSyncSettingsForm: FC = () => {
+  const { t } = useTranslation('admin');
+
+  const { data: keycloakGroupSyncSettings } = useSWRxKeycloakGroupSyncSettings();
+
+  const [formValues, setFormValues] = useState<KeycloakGroupSyncSettings>({
+    keycloakHost: '',
+    keycloakRealm: '',
+    keycloakGroupSyncClientName: '',
+    keycloakGroupSyncClientID: '',
+    keycloakGroupSyncClientSecret: '',
+    autoGenerateUserOnKeycloakGroupSync: false,
+    preserveDeletedKeycloakGroups: false,
+    keycloakGroupDescriptionAttribute: '',
+  });
+
+  useEffect(() => {
+    if (keycloakGroupSyncSettings != null) {
+      setFormValues(keycloakGroupSyncSettings);
+    }
+  }, [keycloakGroupSyncSettings, setFormValues]);
+
+  const submitHandler = useCallback(async(e) => {
+    e.preventDefault();
+    try {
+      await apiv3Put('/external-user-groups/keycloak/sync-settings', formValues);
+      toastSuccess(t('external_user_group.keycloak.updated_group_sync_settings'));
+    }
+    catch (errs) {
+      toastError(t(errs[0]?.message));
+    }
+  }, [formValues, t]);
+
+  return (
+    <>
+      <h3 className="border-bottom mb-3">{t('external_user_group.keycloak.group_sync_settings')}</h3>
+      <form onSubmit={submitHandler}>
+        <div className="row form-group">
+          <label
+            htmlFor="keycloakHost"
+            className="text-left text-md-right col-md-3 col-form-label"
+          >
+            {t('external_user_group.keycloak.host')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              type="text"
+              name="keycloakHost"
+              id="keycloakHost"
+              value={formValues.keycloakHost}
+              onChange={e => setFormValues({ ...formValues, keycloakHost: e.target.value })}
+            />
+            <p className="form-text text-muted">
+              <small>{t('external_user_group.keycloak.host_detail')}</small>
+            </p>
+          </div>
+        </div>
+        <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>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              required
+              type="text"
+              name="keycloakRealm"
+              id="keycloakRealm"
+              value={formValues.keycloakRealm}
+              onChange={e => setFormValues({ ...formValues, keycloakRealm: e.target.value })}
+            />
+            <p className="form-text text-muted">
+              <small>
+                {t('external_user_group.keycloak.realm_detail')} <br />
+              </small>
+            </p>
+          </div>
+        </div>
+        <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>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              required
+              type="text"
+              name="keycloakGroupSyncClientName"
+              id="keycloakGroupSyncClientName"
+              value={formValues.keycloakGroupSyncClientName}
+              onChange={e => setFormValues({ ...formValues, keycloakGroupSyncClientName: e.target.value })}
+            />
+            <p className="form-text text-muted">
+              <small>
+                {t('external_user_group.keycloak.group_sync_client_name_detail')} <br />
+              </small>
+            </p>
+          </div>
+        </div>
+        <div className="row form-group">
+          <label htmlFor="keycloakGroupSyncClientID" className="text-left text-md-right col-md-3 col-form-label">
+            {t('external_user_group.keycloak.group_sync_client_id')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              required
+              type="text"
+              name="keycloakGroupSyncClientID"
+              id="keycloakGroupSyncClientID"
+              value={formValues.keycloakGroupSyncClientID}
+              onChange={e => setFormValues({ ...formValues, keycloakGroupSyncClientID: e.target.value })}
+            />
+            <p className="form-text text-muted">
+              <small>
+                {t('external_user_group.keycloak.group_sync_client_id_detail')} <br />
+              </small>
+            </p>
+          </div>
+        </div>
+        <div className="row form-group">
+          <label htmlFor="keycloakGroupSyncClientSecret" className="text-left text-md-right col-md-3 col-form-label">
+            {t('external_user_group.keycloak.group_sync_client_secret')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              required
+              type="text"
+              name="keycloakGroupSyncClientSecret"
+              id="keycloakGroupSyncClientSecret"
+              value={formValues.keycloakGroupSyncClientSecret}
+              onChange={e => setFormValues({ ...formValues, keycloakGroupSyncClientSecret: e.target.value })}
+            />
+            <p className="form-text text-muted">
+              <small>
+                {t('external_user_group.keycloak.group_sync_client_secret_detail')} <br />
+              </small>
+            </p>
+          </div>
+        </div>
+        <div className="row form-group">
+          <label
+            className="text-left text-md-right col-md-3 col-form-label"
+          >
+            {/* {t('external_user_group.auto_generate_user_on_sync')} */}
+          </label>
+          <div className="col-md-6">
+            <div className="custom-control custom-checkbox custom-checkbox-info">
+              <input
+                type="checkbox"
+                className="custom-control-input"
+                name="autoGenerateUserOnKeycloakGroupSync"
+                id="autoGenerateUserOnKeycloakGroupSync"
+                checked={formValues.autoGenerateUserOnKeycloakGroupSync}
+                onChange={() => setFormValues({ ...formValues, autoGenerateUserOnKeycloakGroupSync: !formValues.autoGenerateUserOnKeycloakGroupSync })}
+              />
+              <label
+                className="custom-control-label"
+                htmlFor="autoGenerateUserOnKeycloakGroupSync"
+              >
+                {t('external_user_group.auto_generate_user_on_sync')}
+              </label>
+            </div>
+          </div>
+        </div>
+        <div className="row form-group">
+          <label
+            className="text-left text-md-right col-md-3 col-form-label"
+          >
+            {/* {t('external_user_group.keycloak.preserve_deleted_keycloak_groups')} */}
+          </label>
+          <div className="col-md-6">
+            <div className="custom-control custom-checkbox custom-checkbox-info">
+              <input
+                type="checkbox"
+                className="custom-control-input"
+                name="preserveDeletedKeycloakGroups"
+                id="preserveDeletedKeycloakGroups"
+                checked={formValues.preserveDeletedKeycloakGroups}
+                onChange={() => setFormValues({ ...formValues, preserveDeletedKeycloakGroups: !formValues.preserveDeletedKeycloakGroups })}
+              />
+              <label
+                className="custom-control-label"
+                htmlFor="preserveDeletedKeycloakGroups"
+              >
+                {t('external_user_group.keycloak.preserve_deleted_keycloak_groups')}
+              </label>
+            </div>
+          </div>
+        </div>
+        <div className="px-5">
+          <h4 className="border-bottom mb-3">Attribute Mapping ({t('optional')})</h4>
+        </div>
+        <div className="row form-group">
+          <label htmlFor="keycloakGroupDescriptionAttribute" className="text-left text-md-right col-md-3 col-form-label">
+            {t('Description')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              type="text"
+              name="keycloakGroupDescriptionAttribute"
+              id="keycloakGroupDescriptionAttribute"
+              value={formValues.keycloakGroupDescriptionAttribute || ''}
+              onChange={e => setFormValues({ ...formValues, keycloakGroupDescriptionAttribute: e.target.value })}
+            />
+            <p className="form-text text-muted">
+              <small>
+                {t('external_user_group.description_mapper_detail')}
+              </small>
+            </p>
+          </div>
+        </div>
+
+        <div className="row my-3">
+          <div className="offset-3 col-5">
+            <button
+              type="submit"
+              className="btn btn-primary"
+            >
+              {t('Update')}
+            </button>
+          </div>
+        </div>
+      </form>
+    </>
+  );
+};

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

@@ -142,7 +142,7 @@ export const LdapGroupSyncSettingsForm: FC = () => {
           <label
             className="text-left text-md-right col-md-3 col-form-label"
           >
-            {/* {t('external_user_group.ldap.auto_generate_user_on_sync')} */}
+            {/* {t('external_user_group.auto_generate_user_on_sync')} */}
           </label>
           <div className="col-md-6">
             <div className="custom-control custom-checkbox custom-checkbox-info">
@@ -158,7 +158,7 @@ export const LdapGroupSyncSettingsForm: FC = () => {
                 className="custom-control-label"
                 htmlFor="autoGenerateUserOnLdapGroupSync"
               >
-                {t('external_user_group.ldap.auto_generate_user_on_sync')}
+                {t('external_user_group.auto_generate_user_on_sync')}
               </label>
             </div>
           </div>
@@ -225,7 +225,7 @@ export const LdapGroupSyncSettingsForm: FC = () => {
             />
             <p className="form-text text-muted">
               <small>
-                {t('external_user_group.ldap.description_mapper_detail')}
+                {t('external_user_group.description_mapper_detail')}
               </small>
             </p>
           </div>

+ 12 - 1
apps/app/src/features/external-user-group/client/stores/external-user-group.ts

@@ -3,7 +3,9 @@ import useSWR, { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
-import { IExternalUserGroupHasId, IExternalUserGroupRelationHasId, LdapGroupSyncSettings } from '~/features/external-user-group/interfaces/external-user-group';
+import {
+  IExternalUserGroupHasId, IExternalUserGroupRelationHasId, KeycloakGroupSyncSettings, LdapGroupSyncSettings,
+} from '~/features/external-user-group/interfaces/external-user-group';
 import {
   ChildUserGroupListResult, IUserGroupRelationHasIdPopulatedUser, UserGroupListResult, UserGroupRelationListResult,
 } from '~/interfaces/user-group-response';
@@ -17,6 +19,15 @@ export const useSWRxLdapGroupSyncSettings = (): SWRResponse<LdapGroupSyncSetting
   );
 };
 
+export const useSWRxKeycloakGroupSyncSettings = (): SWRResponse<KeycloakGroupSyncSettings, Error> => {
+  return useSWR(
+    '/external-user-groups/keycloak/sync-settings',
+    endpoint => apiv3Get(endpoint).then((response) => {
+      return response.data;
+    }),
+  );
+};
+
 export const useSWRxMyExternalUserGroups = (shouldFetch: boolean): SWRResponse<IExternalUserGroupHasId[], Error> => {
   return useSWR(
     shouldFetch ? '/me/external-user-groups' : null,

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

@@ -34,6 +34,17 @@ export interface LdapGroupSyncSettings {
   ldapGroupDescriptionAttribute?: string
 }
 
+export interface KeycloakGroupSyncSettings {
+  keycloakHost: string
+  keycloakRealm: string
+  keycloakGroupSyncClientName: string
+  keycloakGroupSyncClientID: string
+  keycloakGroupSyncClientSecret: string
+  autoGenerateUserOnKeycloakGroupSync: boolean
+  preserveDeletedKeycloakGroups: boolean
+  keycloakGroupDescriptionAttribute?: string
+}
+
 export type ExternalUserInfo = {
   id: string, // external user id
   username: string,

+ 54 - 1
apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts

@@ -45,6 +45,16 @@ module.exports = (crowi: Crowi): Router => {
       body('ldapGroupNameAttribute').optional({ nullable: true }).isString(),
       body('ldapGroupDescriptionAttribute').optional({ nullable: true }).isString(),
     ],
+    keycloakSyncSettings: [
+      body('keycloakHost').exists({ checkFalsy: true }).isString(),
+      body('keycloakRealm').exists({ checkFalsy: true }).isString(),
+      body('keycloakGroupSyncClientName').exists({ checkFalsy: true }).isString(),
+      body('keycloakGroupSyncClientID').exists({ checkFalsy: true }).isString(),
+      body('keycloakGroupSyncClientSecret').exists({ checkFalsy: true }).isString(),
+      body('autoGenerateUserOnKeycloakGroupSync').exists().isBoolean(),
+      body('preserveDeletedKeycloakGroups').exists().isBoolean(),
+      body('keycloakGroupDescriptionAttribute').optional({ nullable: true }).isString(),
+    ],
     listChildren: [
       query('parentIds').optional().isArray(),
       query('includeGrandChildren').optional().isBoolean(),
@@ -197,7 +207,7 @@ module.exports = (crowi: Crowi): Router => {
     }
   });
 
-  router.get('/ldap/sync-settings', loginRequiredStrictly, adminRequired, validators.ldapSyncSettings, (req: AuthorizedRequest, res: ApiV3Response) => {
+  router.get('/ldap/sync-settings', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
     const settings = {
       ldapGroupSearchBase: configManager?.getConfig('crowi', 'external-user-group:ldap:groupSearchBase'),
       ldapGroupMembershipAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttribute'),
@@ -212,6 +222,21 @@ module.exports = (crowi: Crowi): Router => {
     return res.apiv3(settings);
   });
 
+  router.get('/keycloak/sync-settings', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
+    const settings = {
+      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'),
+      keycloakGroupSyncClientID: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientID'),
+      keycloakGroupSyncClientSecret: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientSecret'),
+      autoGenerateUserOnKeycloakGroupSync: configManager?.getConfig('crowi', 'external-user-group:keycloak:autoGenerateUserOnGroupSync'),
+      preserveDeletedKeycloakGroups: configManager?.getConfig('crowi', 'external-user-group:keycloak:preserveDeletedGroups'),
+      keycloakGroupDescriptionAttribute: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupDescriptionAttribute'),
+    };
+
+    return res.apiv3(settings);
+  });
+
   router.put('/ldap/sync-settings', loginRequiredStrictly, adminRequired, validators.ldapSyncSettings, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const errors = validationResult(req);
     if (!errors.isEmpty()) {
@@ -244,6 +269,34 @@ module.exports = (crowi: Crowi): Router => {
     }
   });
 
+  router.put('/keycloak/sync-settings', loginRequiredStrictly, adminRequired, validators.keycloakSyncSettings,
+    async(req: AuthorizedRequest, res: ApiV3Response) => {
+      const errors = validationResult(req);
+      if (!errors.isEmpty()) {
+        return res.apiv3Err('external_user_group.invalid_sync_settings', 400);
+      }
+
+      const params = {
+        '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:groupSyncClientID': req.body.keycloakGroupSyncClientID,
+        'external-user-group:keycloak:groupSyncClientSecret': req.body.keycloakGroupSyncClientSecret,
+        'external-user-group:keycloak:autoGenerateUserOnGroupSync': req.body.autoGenerateUserOnKeycloakGroupSync,
+        'external-user-group:keycloak:preserveDeletedGroups': req.body.preserveDeletedKeycloakGroups,
+        'external-user-group:keycloak:groupDescriptionAttribute': req.body.keycloakGroupDescriptionAttribute,
+      };
+
+      try {
+        await configManager.updateConfigsInTheSameNamespace('crowi', params, true);
+        return res.apiv3({}, 204);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err, 500);
+      }
+    });
+
   router.put('/ldap/sync', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
     try {
       const ldapUserGroupSyncService = new LdapUserGroupSyncService(crowi.passportService, req.user.name, req.body.password);