ソースを参照

Merge pull request #7857 from weseek/feat/ldap-group-sync

feat: LDAP/Keycloak group sync
Yuki Takei 2 年 前
コミット
578d0744d5
100 ファイル変更5989 行追加1428 行削除
  1. 4 0
      apps/app/package.json
  2. 52 1
      apps/app/public/static/locales/en_US/admin.json
  3. 53 1
      apps/app/public/static/locales/ja_JP/admin.json
  4. 53 1
      apps/app/public/static/locales/zh_CN/admin.json
  5. 1 1
      apps/app/resource/search/mappings-es7.json
  6. 1 1
      apps/app/resource/search/mappings-es8-for-ci.json
  7. 1 1
      apps/app/resource/search/mappings-es8.json
  8. 8 2
      apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts
  9. 8 2
      apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts
  10. 3 2
      apps/app/src/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx
  11. 2 2
      apps/app/src/components/Admin/Security/LdapSecuritySettingContents.jsx
  12. 1 1
      apps/app/src/components/Admin/Security/OidcSecuritySettingContents.jsx
  13. 22 10
      apps/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  14. 10 1
      apps/app/src/components/Admin/UserGroup/UserGroupModal.tsx
  15. 6 0
      apps/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  16. 41 23
      apps/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  17. 63 38
      apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  18. 35 36
      apps/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx
  19. 50 0
      apps/app/src/components/Admin/UserGroupDetail/use-user-group-resource.ts
  20. 65 31
      apps/app/src/components/PageAlert/FixPageGrantAlert.tsx
  21. 10 1
      apps/app/src/components/PageAlert/PageGrantAlert.tsx
  22. 4 3
      apps/app/src/components/PageEditor.tsx
  23. 4 2
      apps/app/src/components/PageEditorByHackmd.tsx
  24. 3 4
      apps/app/src/components/SavePageControls.tsx
  25. 56 44
      apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx
  26. 1 0
      apps/app/src/components/SavePageControls/GrantSelector/index.ts
  27. 38 0
      apps/app/src/components/SavePageControls/GrantSelector/use-my-user-groups.ts
  28. 1 1
      apps/app/src/components/Sidebar/PageTree/Item.tsx
  29. 181 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement.tsx
  30. 21 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/KeycloakGroupManagement.tsx
  31. 241 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/KeycloakGroupSyncSettingsForm.tsx
  32. 67 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupManagement.tsx
  33. 247 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupSyncSettingsForm.tsx
  34. 171 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/SyncExecution.tsx
  35. 106 0
      apps/app/src/features/external-user-group/client/stores/external-user-group.ts
  36. 62 0
      apps/app/src/features/external-user-group/interfaces/external-user-group.ts
  37. 126 0
      apps/app/src/features/external-user-group/server/models/external-user-group-relation.integ.ts
  38. 54 0
      apps/app/src/features/external-user-group/server/models/external-user-group-relation.ts
  39. 73 0
      apps/app/src/features/external-user-group/server/models/external-user-group.integ.ts
  40. 64 0
      apps/app/src/features/external-user-group/server/models/external-user-group.ts
  41. 55 0
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group-relation.ts
  42. 393 0
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  43. 224 0
      apps/app/src/features/external-user-group/server/service/external-user-group-sync.ts
  44. 210 0
      apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.integ.ts
  45. 168 0
      apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.ts
  46. 156 0
      apps/app/src/features/external-user-group/server/service/ldap-user-group-sync.ts
  47. 9 2
      apps/app/src/interfaces/page-grant.ts
  48. 3 2
      apps/app/src/interfaces/page-operation.ts
  49. 5 4
      apps/app/src/interfaces/page.ts
  50. 12 11
      apps/app/src/interfaces/user-group-response.ts
  51. 23 0
      apps/app/src/interfaces/websocket.ts
  52. 1 1
      apps/app/src/migrations/20200402160380-remove-deleteduser-from-relationgroup.js
  53. 160 0
      apps/app/src/migrations/20230723061824-granted-group-to-array-of-objects.js
  54. 11 8
      apps/app/src/pages/[[...path]].page.tsx
  55. 4 2
      apps/app/src/pages/admin/user-group-detail/[userGroupId].page.tsx
  56. 34 16
      apps/app/src/server/crowi/index.js
  57. 6 0
      apps/app/src/server/models/config.ts
  58. 0 187
      apps/app/src/server/models/external-account.js
  59. 151 0
      apps/app/src/server/models/external-account.ts
  60. 0 3
      apps/app/src/server/models/index.js
  61. 4 4
      apps/app/src/server/models/interfaces/page-operation.ts
  62. 48 49
      apps/app/src/server/models/obsolete-page.js
  63. 23 2
      apps/app/src/server/models/page-operation.ts
  64. 46 15
      apps/app/src/server/models/page.ts
  65. 0 391
      apps/app/src/server/models/user-group-relation.js
  66. 374 0
      apps/app/src/server/models/user-group-relation.ts
  67. 12 18
      apps/app/src/server/models/user-group.ts
  68. 0 1
      apps/app/src/server/routes/admin.js
  69. 4 1
      apps/app/src/server/routes/apiv3/index.js
  70. 54 0
      apps/app/src/server/routes/apiv3/me.ts
  71. 33 20
      apps/app/src/server/routes/apiv3/page.js
  72. 2 2
      apps/app/src/server/routes/apiv3/pages.js
  73. 2 1
      apps/app/src/server/routes/apiv3/personal-setting.js
  74. 1 2
      apps/app/src/server/routes/apiv3/user-group-relation.js
  75. 23 13
      apps/app/src/server/routes/apiv3/user-group.js
  76. 4 2
      apps/app/src/server/routes/apiv3/users.js
  77. 0 3
      apps/app/src/server/routes/index.js
  78. 11 56
      apps/app/src/server/routes/login-passport.js
  79. 0 103
      apps/app/src/server/routes/me.js
  80. 5 5
      apps/app/src/server/routes/page.js
  81. 6 5
      apps/app/src/server/routes/search.ts
  82. 72 0
      apps/app/src/server/service/external-account.ts
  83. 173 0
      apps/app/src/server/service/ldap.ts
  84. 153 71
      apps/app/src/server/service/page-grant.ts
  85. 72 65
      apps/app/src/server/service/page.ts
  86. 1 1
      apps/app/src/server/service/passport.ts
  87. 12 9
      apps/app/src/server/service/search-delegator/elasticsearch.ts
  88. 3 2
      apps/app/src/server/service/search.ts
  89. 19 14
      apps/app/src/server/service/user-group.ts
  90. 51 0
      apps/app/src/server/util/compare-objectId.spec.ts
  91. 22 3
      apps/app/src/server/util/compare-objectId.ts
  92. 27 0
      apps/app/src/server/util/granted-group.ts
  93. 34 20
      apps/app/src/stores/user-group.tsx
  94. 51 0
      apps/app/src/utils/promise.spec.ts
  95. 35 0
      apps/app/src/utils/promise.ts
  96. 1 1
      apps/app/test/integration/models/page.test.js
  97. 337 62
      apps/app/test/integration/models/v5.page.test.js
  98. 266 0
      apps/app/test/integration/service/external-user-group-sync.test.ts
  99. 263 0
      apps/app/test/integration/service/ldap-user-group-sync.test.ts
  100. 111 42
      apps/app/test/integration/service/page-grant.test.js

+ 4 - 0
apps/app/package.json

@@ -53,6 +53,7 @@
   "// comments for dependencies": {
     "escape-string-regexp": "5.0.0 or above exports only ESM",
     "string-width": "5.0.0 or above exports only ESM.",
+    "@keycloak/keycloak-admin-client": "19.0.0 or above exports only ESM.",
     "remark-wiki-link": "!!DO NOT REMOVE!! including 'mdast-util-wiki-link' and 'micromark-extension-wiki-link' required by pukiwiki-like-linker"
   },
   "dependencies": {
@@ -76,9 +77,11 @@
     "@growi/remark-growi-directive": "link:../../packages/remark-growi-directive",
     "@growi/remark-lsx": "link:../../packages/remark-lsx",
     "@growi/slack": "link:../../packages/slack",
+    "@keycloak/keycloak-admin-client": "^18.0.0",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
     "@types/jest": "^29.5.2",
+    "@types/ldapjs": "^2.2.5",
     "JSONStream": "^1.3.5",
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
@@ -124,6 +127,7 @@
     "i18next-localstorage-backend": "^4.0.0",
     "is-absolute-url": "^4.0.1",
     "is-iso-date": "^0.0.1",
+    "ldapjs": "^3.0.2",
     "lucene-query-parser": "^1.2.0",
     "markdown-table": "^1.1.1",
     "md5": "^2.2.1",

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

@@ -9,6 +9,7 @@
   "specified_users": "Specified users",
   "only_me": "Only me",
   "only_inside_the_group": "Only inside the group",
+  "optional": "Optional",
   "security_settings": {
     "security_settings": "Security Settings",
     "scope_of_page_disclosure": "Scope of page disclosure",
@@ -94,7 +95,6 @@
     "enable_link_sharing": "Enable link sharing",
     "all_share_links": "All share links",
     "configuration": " Configuration",
-    "optional": "Optional",
     "Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",
     "Treat username matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>username</code>.",
     "Treat email matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>email</code> match",
@@ -1064,6 +1064,57 @@
     "error_generate_growi_archive": "Failed to generate GROWI archive file",
     "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
   },
+  "external_user_group": {
+    "management": "External Group Management",
+    "execute_sync": "Execute Sync",
+    "sync": "Sync",
+    "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",
+    "only_description_edit_allowed": "Only description can be edited for external user groups",
+    "sync_being_executed": "There is a running external group sync process started by you or another user. The next sync cannot be executed until this finishes.",
+    "sync_succeeded": "External group sync succeeded",
+    "sync_failed": "External group sync failed",
+    "provider": "Provider",
+    "confirmation_before_sync": "Confirmation before sync",
+    "execution_time_warning": "If the number of groups or users is large, it might take a while until sync finishes",
+    "parallel_sync_forbidden": "While sync is executing, you cannot execute a different external group sync",
+    "ldap": {
+      "group_sync_settings": "LDAP Group Sync Settings",
+      "group_search_base_DN": "Group Search Base DN",
+      "group_search_base_dn_detail": "The base DN for searching groups. The value set in security settings will be used if not set here.",
+      "membership_attribute": "Membership Attribute",
+      "membership_attribute_detail": "Attribute of the group object which indicates user membership info",
+      "membership_attribute_type": "Membership Attribute Type",
+      "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.",
+      "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",
+      "password": "Password",
+      "password_detail": "Login password is necessary because Bind type is set to User Bind",
+      "auth_not_set": "Enable and configure LDAP auth in security settings before sync"
+    },
+    "keycloak": {
+      "group_sync_settings": "Keycloak Group Sync Settings",
+      "host": "Host",
+      "host_detail": "Keycloak host URL",
+      "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",
+      "preserve_deleted_keycloak_groups": "Preserve Deleted Keycloak Groups",
+      "auth_not_set": "Enable and configure OIDC or SAML with Keycloak in security settings before 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."
+  },
   "toaster": {
     "grant_user_admin": "Succeeded to grant {{username}} admin",
     "revoke_user_admin": "Succeeded to revoke {{username}} admin",

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

@@ -10,6 +10,7 @@
   "Created": "作成日",
   "Edit": "編集",
   "Description": "説明",
+  "Execute": "実行",
   "last_login": "最終ログイン",
   "wiki_management_homepage": "Wiki管理トップ",
   "public": "公開",
@@ -17,6 +18,7 @@
   "specified_users": "特定ユーザーのみ",
   "only_me": "自分のみ",
   "only_inside_the_group": "特定グループのみ",
+  "optional": "オプション",
   "security_settings": {
     "security_settings": "セキュリティ設定",
     "scope_of_page_disclosure": "ページの公開範囲",
@@ -102,7 +104,6 @@
     "enable_link_sharing": "リンクのシェアを許可",
     "all_share_links": "全てのシェアリンク",
     "configuration": "設定",
-    "optional": "オプション",
     "Treat username matching as identical": "新規ログイン時、<code>username</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
     "Treat username matching as identical_warn": "警告: <code>username</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
     "Treat email matching as identical": "新規ログイン時、<code>email</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
@@ -1073,6 +1074,57 @@
     "error_generate_growi_archive": "GROWI アーカイブファイルの作成に失敗しました",
     "error_send_growi_archive": "GROWI アーカイブファイルの送信に失敗しました"
   },
+  "external_user_group": {
+    "management": "外部グループ管理",
+    "execute_sync": "同期実行",
+    "sync": "同期",
+    "invalid_sync_settings": "同期設定に誤りがあります",
+    "update_sync_settings_failed": "同期設定の更新が失敗しました",
+    "description_form_detail": "同期設定で「説明」の mapper が設定されている場合、編集内容は再同期によって上書きされることに注意してください",
+    "only_description_edit_allowed": "外部グループは説明の編集のみが可能です",
+    "sync_being_executed": "自身または他のユーザが実行した外部グループ同期が終了するまで次の実行ができません",
+    "sync_succeeded": "外部グループ同期に成功しました",
+    "sync_failed": "外部グループ同期に失敗しました",
+    "provider": "プロバイダ",
+    "confirmation_before_sync": "同期実行前の確認",
+    "execution_time_warning": "同期するグループやユーザが多い場合、同期が完了するまでに時間を要します",
+    "parallel_sync_forbidden": "同期実行中は、他の外部グループ同期は実行できません",
+    "ldap": {
+      "group_sync_settings": "LDAP グループ同期設定",
+      "group_search_base_DN": "グループ検索ベース DN",
+      "group_search_base_dn_detail": "グループ検索をするベース DN。設定されていない場合、セキュリティ設定で設定されたものが利用されます。",
+      "membership_attribute": "所属メンバーを表す LDAP 属性",
+      "membership_attribute_detail": "グループの所属メンバーを表すグループオブジェクトの属性",
+      "membership_attribute_type": "「所属メンバーを表す LDAP 属性」値の種類",
+      "membership_attribute_type_detail": "グループの所属メンバーを表すグループオブジェクトの属性値は DN か UID か",
+      "child_group_attribute": "子グループを表す LDAP 属性",
+      "child_group_attribute_detail": "グループに所属する子グループを表すグループオブジェクトの属性。属性値は DN である必要があります。",
+      "preserve_deleted_ldap_groups": "LDAP から削除されたグループを GROWI に残す",
+      "name_mapper_detail": "グループの「名前」として読み込む属性",
+      "updated_group_sync_settings": "LDAP グループ同期設定を更新しました",
+      "password": "パスワード",
+      "password_detail": "認証設定がユーザ Bind のため、ログイン時のパスワードの入力が必要となります",
+      "auth_not_set": "同期実行前にセキュリティ設定で LDAP 認証を有効にし、設定してください"
+    },
+    "keycloak": {
+      "group_sync_settings": "Keycloak グループ同期設定",
+      "host": "Host",
+      "host_detail": "Keycloak ホスト URL",
+      "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 グループ同期設定を更新しました",
+      "preserve_deleted_keycloak_groups": "Keycloak から削除されたグループを GROWI に残す",
+      "auth_not_set": "同期実行前にセキュリティ設定で Keycloak を使った OIDC または SAML 認証を有効にし、設定してください"
+    },
+    "auto_generate_user_on_sync": "作成されていない GROWI アカウントを自動生成する",
+    "description_mapper_detail": "グループの「説明」として読み込む属性。「説明」は同期後に編集可能です。ただし、mapper が設定されている場合、編集内容は再同期によって上書きされます。"
+  },
   "toaster": {
     "grant_user_admin": "{{username}}を管理者に設定しました",
     "revoke_user_admin": "{{username}}を管理者から外しました",

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

@@ -10,6 +10,7 @@
   "Page": "页面",
   "Edit": "编辑",
   "Description": "描述",
+  "Execute": "执行",
   "last_login": "上次登录",
   "wiki_management_homepage": "Wiki管理首页",
   "public": "公共",
@@ -17,6 +18,7 @@
   "specified_users": "仅指定用户",
   "only_me": "只有我",
   "only_inside_the_group": "仅组内",
+  "optional": "可选的",
   "security_settings": {
     "security_settings": "安全设置",
     "scope_of_page_disclosure": "页面公开范围",
@@ -102,7 +104,6 @@
     "enable_link_sharing": "启用链接共享",
     "all_share_links": "所有共享链接",
 		"configuration": " 配置",
-		"optional": "可选的",
 		"Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",
 		"Treat username matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>username</code>.",
 		"Treat email matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>email</code> match",
@@ -1072,6 +1073,57 @@
     "error_generate_growi_archive": "Failed to generate GROWI archive file",
     "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
   },
+  "external_user_group": {
+    "management": "External Group Management",
+    "execute_sync": "Execute Sync",
+    "sync": "Sync",
+    "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",
+    "only_description_edit_allowed": "Only description can be edited for external user groups",
+    "sync_being_executed": "There is a running external group sync process started by you or another user. The next sync cannot be executed until this finishes.",
+    "sync_succeeded": "External group sync succeeded",
+    "sync_failed": "External group sync failed",
+    "provider": "Provider",
+    "confirmation_before_sync": "Confirmation before sync",
+    "execution_time_warning": "If the number of groups or users is large, it might take a while until sync finishes",
+    "parallel_sync_forbidden": "While sync is executing, you cannot execute a different external group sync",
+    "ldap": {
+      "group_sync_settings": "LDAP Group Sync Settings",
+      "group_search_base_DN": "Group Search Base DN",
+      "group_search_base_dn_detail": "The base DN for searching groups. The value set in security settings will be used if not set here.",
+      "membership_attribute": "Membership Attribute",
+      "membership_attribute_detail": "Attribute of the group object which indicates user membership info",
+      "membership_attribute_type": "Membership Attribute Type",
+      "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.",
+      "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",
+      "password": "Password",
+      "password_detail": "Login password is necessary because Bind type is set to User Bind",
+      "auth_not_set": "Enable and configure LDAP auth in security settings before sync"
+    },
+    "keycloak": {
+      "group_sync_settings": "Keycloak Group Sync Settings",
+      "host": "Host",
+      "host_detail": "Keycloak host URL",
+      "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",
+      "preserve_deleted_keycloak_groups": "Preserve Deleted Keycloak Groups",
+      "auth_not_set": "Enable and configure OIDC or SAML with Keycloak in security settings before 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."
+  },
   "toaster": {
     "grant_user_admin": "Succeeded to grant {{username}} admin",
     "revoke_user_admin": "Succeeded to revoke {{username}} admin",

+ 1 - 1
apps/app/resource/search/mappings-es7.json

@@ -96,7 +96,7 @@
       "granted_users": {
         "type": "keyword"
       },
-      "granted_group": {
+      "granted_groups": {
         "type": "keyword"
       },
       "created_at": {

+ 1 - 1
apps/app/resource/search/mappings-es8-for-ci.json

@@ -99,7 +99,7 @@
       "granted_users": {
         "type": "keyword"
       },
-      "granted_group": {
+      "granted_groups": {
         "type": "keyword"
       },
       "created_at": {

+ 1 - 1
apps/app/resource/search/mappings-es8.json

@@ -96,7 +96,7 @@
       "granted_users": {
         "type": "keyword"
       },
-      "granted_group": {
+      "granted_groups": {
         "type": "keyword"
       },
       "created_at": {

+ 8 - 2
apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts

@@ -44,12 +44,18 @@ export const useDrawioModalLauncherForView = (opts?: {
     const currentMarkdown = currentPage.revision.body;
     const newMarkdown = mdu.replaceDrawioInMarkdown(drawioMxFile, currentMarkdown, bol, eol);
 
+    const grantUserGroupIds = currentPage.grantedGroups.map((g) => {
+      return {
+        type: g.type,
+        item: g.item._id,
+      };
+    });
+
     const optionsToSave: OptionsToSave = {
       isSlackEnabled: false,
       slackChannels: '',
       grant: currentPage.grant,
-      grantUserGroupId: currentPage.grantedGroup?._id,
-      grantUserGroupName: currentPage.grantedGroup?.name,
+      grantUserGroupIds,
       pageTags: tagsInfo.tags,
     };
 

+ 8 - 2
apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts

@@ -43,12 +43,18 @@ export const useHandsontableModalLauncherForView = (opts?: {
     const currentMarkdown = currentPage.revision.body;
     const newMarkdown = mtu.replaceMarkdownTableInMarkdown(table, currentMarkdown, bol, eol);
 
+    const grantUserGroupIds = currentPage.grantedGroups.map((g) => {
+      return {
+        type: g.type,
+        item: g.item._id,
+      };
+    });
+
     const optionsToSave: OptionsToSave = {
       isSlackEnabled: false,
       slackChannels: '',
       grant: currentPage.grant,
-      grantUserGroupId: currentPage.grantedGroup?._id,
-      grantUserGroupName: currentPage.grantedGroup?.name,
+      grantUserGroupIds,
       pageTags: tagsInfo.tags,
     };
 

+ 3 - 2
apps/app/src/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx

@@ -3,6 +3,7 @@ import React from 'react';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 
+import { SocketEventName } from '~/interfaces/websocket';
 import { useAdminSocket } from '~/stores/socket-io';
 
 import LabeledProgressBar from '../Common/LabeledProgressBar';
@@ -27,7 +28,7 @@ class RebuildIndexControls extends React.Component {
     const { socket } = this.props;
 
     if (socket != null) {
-      socket.on('addPageProgress', (data) => {
+      socket.on(SocketEventName.AddPageProgress, (data) => {
         this.setState({
           total: data.totalCount,
           current: data.count,
@@ -35,7 +36,7 @@ class RebuildIndexControls extends React.Component {
         });
       });
 
-      socket.on('finishAddPage', (data) => {
+      socket.on(SocketEventName.FinishAddPage, (data) => {
         this.setState({
           total: data.totalCount,
           current: data.count,

+ 2 - 2
apps/app/src/components/Admin/Security/LdapSecuritySettingContents.jsx

@@ -235,7 +235,7 @@ class LdapSecuritySettingContents extends React.Component {
             </div>
 
             <h3 className="alert-anchor border-bottom">
-              Attribute Mapping ({t('security_settings.optional')})
+              Attribute Mapping ({t('optional')})
             </h3>
 
             <div className="form-group row">
@@ -325,7 +325,7 @@ class LdapSecuritySettingContents extends React.Component {
 
 
             <h3 className="alert-anchor border-bottom">
-              {t('security_settings.ldap.group_search_filter')} ({t('security_settings.optional')})
+              {t('security_settings.ldap.group_search_filter')} ({t('optional')})
             </h3>
 
             <div className="form-group row">

+ 1 - 1
apps/app/src/components/Admin/Security/OidcSecuritySettingContents.jsx

@@ -296,7 +296,7 @@ class OidcSecurityManagementContents extends React.Component {
             </div>
 
             <h3 className="alert-anchor border-bottom">
-              Attribute Mapping ({t('security_settings.optional')})
+              Attribute Mapping ({t('optional')})
             </h3>
 
             <div className="row mb-5 form-group">

+ 22 - 10
apps/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -1,4 +1,6 @@
-import React, { FC, useCallback, useState } from 'react';
+import React, {
+  FC, useCallback, useEffect, useState,
+} from 'react';
 
 import type { IUserGroupHasId } from '@growi/core';
 import dateFnsFormat from 'date-fns/format';
@@ -10,6 +12,7 @@ type Props = {
   selectableParentUserGroups?: IUserGroupHasId[],
   submitButtonLabel: string;
   onSubmit: (targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>) => Promise<void>
+  isExternalGroup?: boolean
 };
 
 export const UserGroupForm: FC<Props> = (props: Props) => {
@@ -17,14 +20,14 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
   const { t } = useTranslation('admin');
 
   const {
-    userGroup, parentUserGroup, selectableParentUserGroups, submitButtonLabel, onSubmit,
+    userGroup, parentUserGroup, selectableParentUserGroups, submitButtonLabel, onSubmit, isExternalGroup = false,
   } = props;
   /*
    * State
    */
   const [currentName, setName] = useState<string>(userGroup.name);
   const [currentDescription, setDescription] = useState<string>(userGroup.description);
-  const [selectedParent, setSelectedParent] = useState<IUserGroupHasId | undefined>(parentUserGroup);
+  const [selectedParent, setSelectedParent] = useState<IUserGroupHasId | undefined>();
   /*
    * Function
    */
@@ -36,12 +39,16 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
     setDescription(e.target.value);
   }, []);
 
-  const onChangeParerentButtonHandler = useCallback((userGroup: IUserGroupHasId) => {
+  const onChangeParentButtonHandler = useCallback((userGroup: IUserGroupHasId) => {
     if (userGroup._id !== selectedParent?._id) {
       setSelectedParent(userGroup);
     }
   }, [selectedParent, setSelectedParent]);
 
+  useEffect(() => {
+    setSelectedParent(parentUserGroup);
+  }, [parentUserGroup]);
+
   const isSelectableParentUserGroups = selectableParentUserGroups != null && selectableParentUserGroups.length > 0;
 
   const isChildUserGroup = parentUserGroup !== undefined;
@@ -60,7 +67,12 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
 
       <fieldset>
         <h2 className="admin-setting-header">{t('user_group_management.basic_info')}</h2>
-
+        {isExternalGroup
+        && (
+          <div className="mb-3">
+            <small className="text-muted">{t('external_user_group.only_description_edit_allowed')}</small>
+          </div>
+        )}
         {
           userGroup?.createdAt != null && (
             <div className="form-group row">
@@ -74,7 +86,7 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
           <label htmlFor="name" className="col-md-2 col-form-label">
             {t('user_group_management.group_name')}
           </label>
-          <div className="col-md-4">
+          <div className="col-md-4 my-auto">
             <input
               className="form-control"
               type="text"
@@ -83,6 +95,7 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
               value={currentName}
               onChange={onChangeNameHandler}
               required
+              disabled={isExternalGroup}
             />
           </div>
         </div>
@@ -105,9 +118,8 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
               type="button"
               id="dropdownMenuButton"
               data-toggle="dropdown"
-              className={`
-                btn btn-outline-secondary dropdown-toggle mb-3 ${isSelectableParentUserGroups ? '' : 'disabled'}
-              `}
+              className="btn btn-outline-secondary dropdown-toggle mb-3"
+              disabled={isExternalGroup || !isSelectableParentUserGroups}
             >
               {selectedParent?.name ?? messageAtReleaseParentGroup}
             </button>
@@ -121,7 +133,7 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
                           key={userGroup._id}
                           type="button"
                           className={`dropdown-item ${selectedParent?._id === userGroup._id ? 'active' : ''}`}
-                          onClick={() => onChangeParerentButtonHandler(userGroup)}
+                          onClick={() => onChangeParentButtonHandler(userGroup)}
                         >
                           {userGroup.name}
                         </button>

+ 10 - 1
apps/app/src/components/Admin/UserGroup/UserGroupModal.tsx

@@ -14,6 +14,7 @@ type Props = {
   onClickSubmit?: (userGroupData: Partial<IUserGroupHasId>) => Promise<IUserGroupHasId | void>
   isShow?: boolean
   onHide?: () => Promise<void> | void
+  isExternalGroup?: boolean
 };
 
 export const UserGroupModal: FC<Props> = (props: Props) => {
@@ -21,7 +22,7 @@ export const UserGroupModal: FC<Props> = (props: Props) => {
   const { t } = useTranslation('admin');
 
   const {
-    userGroup, buttonLabel, onClickSubmit, isShow, onHide,
+    userGroup, buttonLabel, onClickSubmit, isShow, onHide, isExternalGroup = false,
   } = props;
 
   /*
@@ -86,6 +87,7 @@ export const UserGroupModal: FC<Props> = (props: Props) => {
               value={currentName}
               onChange={onChangeNameHandler}
               required
+              disabled={isExternalGroup}
             />
           </div>
 
@@ -94,6 +96,13 @@ export const UserGroupModal: FC<Props> = (props: Props) => {
               {t('Description')}
             </label>
             <textarea className="form-control" name="description" value={currentDescription} onChange={onChangeDescriptionHandler} />
+            {isExternalGroup && (
+              <p className="form-text text-muted">
+                <small>
+                  {t('external_user_group.description_form_detail')}
+                </small>
+              </p>
+            )}
           </div>
 
           {/* TODO 90732: Add a drop-down to show selectable parents */}

+ 6 - 0
apps/app/src/components/Admin/UserGroup/UserGroupPage.tsx

@@ -6,9 +6,11 @@ import { useTranslation } from 'react-i18next';
 
 import { apiv3Delete, apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
+import { ExternalGroupManagement } from '~/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement';
 import { useIsAclEnabled } from '~/stores/context';
 import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
 
+
 const UserGroupDeleteModal = dynamic(() => import('./UserGroupDeleteModal').then(mod => mod.UserGroupDeleteModal), { ssr: false });
 const UserGroupModal = dynamic(() => import('./UserGroupModal').then(mod => mod.UserGroupModal), { ssr: false });
 const UserGroupTable = dynamic(() => import('./UserGroupTable').then(mod => mod.UserGroupTable), { ssr: false });
@@ -146,6 +148,7 @@ export const UserGroupPage: FC = () => {
 
   return (
     <div data-testid="admin-user-groups">
+      <h2 className="border-bottom">{t('admin:user_group_management.user_group_management')}</h2>
       {
         isAclEnabled ? (
           <div className="mb-3">
@@ -190,6 +193,9 @@ export const UserGroupPage: FC = () => {
         isShow={isDeleteModalShown}
         onHide={hideDeleteModal}
       />
+      <div className="mt-5">
+        <ExternalGroupManagement />
+      </div>
     </div>
   );
 };

+ 41 - 23
apps/app/src/components/Admin/UserGroup/UserGroupTable.tsx

@@ -7,6 +7,8 @@ import dateFnsFormat from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 
+import { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
+
 
 type Props = {
   headerLabel?: string,
@@ -17,6 +19,7 @@ type Props = {
   onEdit?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onRemove?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onDelete?: (userGroup: IUserGroupHasId) => void | Promise<void>,
+  isExternalGroup?: boolean
 };
 
 /*
@@ -53,27 +56,37 @@ const generateGroupIdToChildGroupsMap = (childUserGroups: IUserGroupHasId[]): Re
 };
 
 
-export const UserGroupTable: FC<Props> = (props: Props) => {
+export const UserGroupTable: FC<Props> = ({
+  headerLabel,
+  userGroups,
+  userGroupRelations,
+  childUserGroups,
+  isAclEnabled,
+  onEdit,
+  onRemove,
+  onDelete,
+  isExternalGroup = false,
+}: Props) => {
   const { t } = useTranslation('admin');
 
   /*
    * State
    */
-  const [groupIdToUsersMap, setGroupIdToUsersMap] = useState(generateGroupIdToUsersMap(props.userGroupRelations));
-  const [groupIdToChildGroupsMap, setGroupIdToChildGroupsMap] = useState(generateGroupIdToChildGroupsMap(props.childUserGroups));
+  const [groupIdToUsersMap, setGroupIdToUsersMap] = useState(generateGroupIdToUsersMap(userGroupRelations));
+  const [groupIdToChildGroupsMap, setGroupIdToChildGroupsMap] = useState(generateGroupIdToChildGroupsMap(childUserGroups));
 
   /*
    * Function
    */
   const findUserGroup = (e: React.ChangeEvent<HTMLInputElement>): IUserGroupHasId | undefined => {
     const groupId = e.target.getAttribute('data-user-group-id');
-    return props.userGroups.find((group) => {
+    return userGroups.find((group) => {
       return group._id === groupId;
     });
   };
 
   const onClickEdit = async(e) => {
-    if (props.onEdit == null) {
+    if (onEdit == null) {
       return;
     }
 
@@ -82,11 +95,11 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
       return;
     }
 
-    props.onEdit(userGroup);
+    onEdit(userGroup);
   };
 
   const onClickRemove = async(e) => {
-    if (props.onRemove == null) {
+    if (onRemove == null) {
       return;
     }
 
@@ -96,7 +109,7 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
     }
 
     try {
-      await props.onRemove(userGroup);
+      await onRemove(userGroup);
       userGroup.parent = null;
     }
     catch {
@@ -105,7 +118,7 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
   };
 
   const onClickDelete = (e) => { // no preventDefault
-    if (props.onDelete == null) {
+    if (onDelete == null) {
       return;
     }
 
@@ -114,24 +127,25 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
       return;
     }
 
-    props.onDelete(userGroup);
+    onDelete(userGroup);
   };
 
   /*
    * useEffect
    */
   useEffect(() => {
-    setGroupIdToUsersMap(generateGroupIdToUsersMap(props.userGroupRelations));
-    setGroupIdToChildGroupsMap(generateGroupIdToChildGroupsMap(props.childUserGroups));
-  }, [props.userGroupRelations, props.childUserGroups]);
+    setGroupIdToUsersMap(generateGroupIdToUsersMap(userGroupRelations));
+    setGroupIdToChildGroupsMap(generateGroupIdToChildGroupsMap(childUserGroups));
+  }, [userGroupRelations, childUserGroups]);
 
   return (
     <div data-testid="grw-user-group-table">
-      <h2>{props.headerLabel}</h2>
+      <h3>{headerLabel}</h3>
 
       <table className="table table-bordered table-user-list">
         <thead>
           <tr>
+            {isExternalGroup && <th>{t('external_user_group.provider')}</th>}
             <th>{t('Name')}</th>
             <th>{t('Description')}</th>
             <th>{t('User')}</th>
@@ -141,14 +155,15 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
           </tr>
         </thead>
         <tbody>
-          {props.userGroups.map((group) => {
+          {userGroups.map((group) => {
             const users = groupIdToUsersMap[group._id];
 
             return (
               <tr key={group._id}>
-                {props.isAclEnabled
+                {isExternalGroup && <td>{(group as IExternalUserGroupHasId).provider}</td>}
+                {isAclEnabled
                   ? (
-                    <td><Link href={`/admin/user-group-detail/${group._id}`}>{group.name}</Link></td>
+                    <td><Link href={`/admin/user-group-detail/${group._id}?isExternalGroup=${isExternalGroup}`}>{group.name}</Link></td>
                   )
                   : (
                     <td>{group.name}</td>
@@ -167,9 +182,9 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
                     {groupIdToChildGroupsMap[group._id] != null && groupIdToChildGroupsMap[group._id].map((group) => {
                       return (
                         <li key={group._id} className="list-inline-item badge badge-success">
-                          {props.isAclEnabled
+                          {isAclEnabled
                             ? (
-                              <Link href={`/admin/user-group-detail/${group._id}`}>{group.name}</Link>
+                              <Link href={`/admin/user-group-detail/${group._id}?isExternalGroup=${isExternalGroup}`}>{group.name}</Link>
                             )
                             : (
                               <p>{group.name}</p>
@@ -181,7 +196,7 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
                   </ul>
                 </td>
                 <td>{dateFnsFormat(new Date(group.createdAt), 'yyyy-MM-dd')}</td>
-                {props.isAclEnabled
+                {isAclEnabled
                   ? (
                     <td>
                       <div className="btn-group admin-group-menu">
@@ -197,9 +212,12 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
                           <button className="dropdown-item" type="button" role="button" onClick={onClickEdit} data-user-group-id={group._id}>
                             <i className="icon-fw icon-note"></i> {t('Edit')}
                           </button>
-                          <button className="dropdown-item" type="button" role="button" onClick={onClickRemove} data-user-group-id={group._id}>
-                            <i className="icon-fw fa fa-chain-broken"></i> {t('admin:user_group_management.remove_child_group')}
-                          </button>
+                          {onRemove != null
+                          && (
+                            <button className="dropdown-item" type="button" role="button" onClick={onClickRemove} data-user-group-id={group._id}>
+                              <i className="icon-fw fa fa-chain-broken"></i> {t('admin:user_group_management.remove_child_group')}
+                            </button>
+                          )}
                           <button className="dropdown-item" type="button" role="button" onClick={onClickDelete} data-user-group-id={group._id}>
                             <i className="icon-fw icon-fire text-danger"></i> {t('Delete')}
                           </button>

+ 63 - 38
apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -13,22 +13,25 @@ import {
   apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
 } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
+import { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
 import { SearchTypes, SearchType } from '~/interfaces/user-group';
 import Xss from '~/services/xss';
 import { useIsAclEnabled } from '~/stores/context';
 import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
-import {
-  useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList, useSWRxUserGroup,
-  useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups, useSWRxAncestorUserGroups, useSWRxUserGroupRelations,
-} from '~/stores/user-group';
+import { useSWRxUserGroupPages, useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups } from '~/stores/user-group';
 import loggerFactory from '~/utils/logger';
 
+import {
+  useAncestorUserGroups,
+  useChildUserGroupList, useUserGroup, useUserGroupRelationList, useUserGroupRelations,
+} from './use-user-group-resource';
+
 import styles from './UserGroupDetailPage.module.scss';
 
 const logger = loggerFactory('growi:services:AdminCustomizeContainer');
 
 const UserGroupPageList = dynamic(() => import('./UserGroupPageList'), { ssr: false });
-const UserGroupUserTable = dynamic(() => import('./UserGroupUserTable'), { ssr: false });
+const UserGroupUserTable = dynamic(() => import('./UserGroupUserTable').then(mod => mod.UserGroupUserTable), { ssr: false });
 
 const UserGroupUserModal = dynamic(() => import('./UserGroupUserModal'), { ssr: false });
 
@@ -42,15 +45,16 @@ const UpdateParentConfirmModal = dynamic(() => import('./UpdateParentConfirmModa
 
 type Props = {
   userGroupId: string,
+  isExternalGroup: boolean,
 }
 
 const UserGroupDetailPage = (props: Props): JSX.Element => {
   const { t } = useTranslation('admin');
   const router = useRouter();
   const xss = useMemo(() => new Xss(), []);
-  const { userGroupId: currentUserGroupId } = props;
+  const { userGroupId: currentUserGroupId, isExternalGroup } = props;
 
-  const { data: currentUserGroup } = useSWRxUserGroup(currentUserGroupId);
+  const { data: currentUserGroup } = useUserGroup(currentUserGroupId, isExternalGroup);
 
   const [searchType, setSearchType] = useState<SearchType>(SearchTypes.PARTIAL);
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
@@ -76,26 +80,36 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
    */
   const { data: userGroupPages } = useSWRxUserGroupPages(currentUserGroupId, 10, 0);
 
-  const { data: userGroupRelations, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelations(currentUserGroupId);
+  const { data: userGroupRelations, mutate: mutateUserGroupRelations } = useUserGroupRelations(currentUserGroupId, isExternalGroup);
 
-  const { data: childUserGroupsList, mutate: mutateChildUserGroups } = useSWRxChildUserGroupList(currentUserGroupId ? [currentUserGroupId] : [], true);
+  const { data: childUserGroupsList, mutate: mutateChildUserGroups, updateChild } = useChildUserGroupList(currentUserGroupId, isExternalGroup);
   const childUserGroups = childUserGroupsList != null ? childUserGroupsList.childUserGroups : [];
   const grandChildUserGroups = childUserGroupsList != null ? childUserGroupsList.grandChildUserGroups : [];
   const childUserGroupIds = childUserGroups.map(group => group._id);
 
-  const { data: userGroupRelationList, mutate: mutateUserGroupRelationList } = useSWRxUserGroupRelationList(childUserGroupIds);
+  const { data: userGroupRelationList, mutate: mutateUserGroupRelationList } = useUserGroupRelationList(childUserGroupIds, isExternalGroup);
   const childUserGroupRelations = userGroupRelationList != null ? userGroupRelationList : [];
 
-  const { data: selectableParentUserGroups, mutate: mutateSelectableParentUserGroups } = useSWRxSelectableParentUserGroups(currentUserGroupId);
-  const { data: selectableChildUserGroups, mutate: mutateSelectableChildUserGroups } = useSWRxSelectableChildUserGroups(currentUserGroupId);
+  const { data: selectableParentUserGroups, mutate: mutateSelectableParentUserGroups } = useSWRxSelectableParentUserGroups(
+    isExternalGroup ? null : currentUserGroupId,
+  );
+  const { data: selectableChildUserGroups, mutate: mutateSelectableChildUserGroups } = useSWRxSelectableChildUserGroups(
+    isExternalGroup ? null : currentUserGroupId,
+  );
 
-  const { data: ancestorUserGroups, mutate: mutateAncestorUserGroups } = useSWRxAncestorUserGroups(currentUserGroupId);
+  const { data: ancestorUserGroups, mutate: mutateAncestorUserGroups } = useAncestorUserGroups(currentUserGroupId, isExternalGroup);
 
   const { data: isAclEnabled } = useIsAclEnabled();
 
   const { open: openUpdateParentConfirmModal } = useUpdateUserGroupConfirmModal();
 
-  const parentUserGroup = selectableParentUserGroups?.find(selectableParentUserGroup => selectableParentUserGroup._id === currentUserGroup?.parent);
+  const parentUserGroup = (() => {
+    if (isExternalGroup) {
+      return ancestorUserGroups != null && ancestorUserGroups.length > 1
+        ? ancestorUserGroups[ancestorUserGroups.length - 2] : undefined;
+    }
+    return selectableParentUserGroups?.find(selectableParentUserGroup => selectableParentUserGroup._id === currentUserGroup?.parent);
+  })();
   /*
    * Function
    */
@@ -113,19 +127,26 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
 
   const updateUserGroup = useCallback(async(userGroup: IUserGroupHasId, update: Partial<IUserGroupHasId>, forceUpdateParents: boolean) => {
     const parentId = typeof update.parent === 'string' ? update.parent : update.parent?._id;
-    await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, {
-      name: update.name,
-      description: update.description,
-      parentId: parentId ?? null,
-      forceUpdateParents,
-    });
+    if (isExternalGroup) {
+      await apiv3Put<{ userGroup: IExternalUserGroupHasId }>(`/external-user-groups/${userGroup._id}`, {
+        description: update.description,
+      });
+    }
+    else {
+      await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, {
+        name: update.name,
+        description: update.description,
+        parentId: parentId ?? null,
+        forceUpdateParents,
+      });
+    }
 
     // mutate
     mutateChildUserGroups();
     mutateAncestorUserGroups();
     mutateSelectableChildUserGroups();
     mutateSelectableParentUserGroups();
-  }, [mutateAncestorUserGroups, mutateChildUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups]);
+  }, [mutateAncestorUserGroups, mutateChildUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups, isExternalGroup]);
 
   const onSubmitUpdateGroup = useCallback(
     async(targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>, forceUpdateParents: boolean): Promise<void> => {
@@ -213,23 +234,16 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
 
   const updateChildUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
     try {
-      await apiv3Put(`/user-groups/${userGroupData._id}`, {
-        name: userGroupData.name,
-        description: userGroupData.description,
-        parentId: userGroupData.parent,
-      });
+      updateChild(userGroupData);
 
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup'), ns: 'commons' }));
 
-      // mutate
-      mutateChildUserGroups();
-
       hideUpdateModal();
     }
     catch (err) {
       toastError(err);
     }
-  }, [t, mutateChildUserGroups, hideUpdateModal]);
+  }, [t, updateChild, hideUpdateModal]);
 
   const onClickAddExistingUserGroupButtonHandler = useCallback(async(selectedChild: IUserGroupHasId): Promise<void> => {
     // show confirm modal before submiting
@@ -283,8 +297,9 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
   }, [setSelectedUserGroup, setDeleteModalShown]);
 
   const deleteChildUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
+    const url = isExternalGroup ? `/external-user-groups/${deleteGroupId}` : `/user-groups/${deleteGroupId}`;
     try {
-      const res = await apiv3Delete(`/user-groups/${deleteGroupId}`, {
+      const res = await apiv3Delete(url, {
         actionName,
         transferToUserGroupId,
       });
@@ -300,7 +315,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
     catch (err) {
       toastError(new Error('Unable to delete the groups'));
     }
-  }, [mutateChildUserGroups, setSelectedUserGroup, setDeleteModalShown]);
+  }, [mutateChildUserGroups, setSelectedUserGroup, setDeleteModalShown, isExternalGroup]);
 
   const removeChildUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
     try {
@@ -348,7 +363,11 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
                 { ancestorUserGroup._id === currentUserGroupId ? (
                   <span>{ancestorUserGroup.name}</span>
                 ) : (
-                  <Link href={`/admin/user-group-detail/${ancestorUserGroup._id}`}>
+                  <Link href={{
+                    pathname: `/admin/user-group-detail/${ancestorUserGroup._id}`,
+                    query: { isExternalGroup: 'true' },
+                  }}
+                  >
                     {ancestorUserGroup.name}
                   </Link>
                 ) }
@@ -366,6 +385,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
           selectableParentUserGroups={selectableParentUserGroups}
           submitButtonLabel={t('Update')}
           onSubmit={onClickSubmitForm}
+          isExternalGroup={isExternalGroup}
         />
       </div>
       <h2 className="admin-setting-header mt-4">{t('user_group_management.user_list')}</h2>
@@ -373,6 +393,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
         userGroupRelations={userGroupRelations}
         onClickPlusBtn={() => setIsUserGroupUserModalShown(true)}
         onClickRemoveUserBtn={removeUserByUsername}
+        isExternalGroup={isExternalGroup}
       />
       <UserGroupUserModal
         isOpen={isUserGroupUserModalShown}
@@ -389,11 +410,13 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
       />
 
       <h2 className="admin-setting-header mt-4">{t('user_group_management.child_group_list')}</h2>
-      <UserGroupDropdown
-        selectableUserGroups={selectableChildUserGroups}
-        onClickAddExistingUserGroupButton={onClickAddExistingUserGroupButtonHandler}
-        onClickCreateUserGroupButton={showCreateModal}
-      />
+      {!isExternalGroup && (
+        <UserGroupDropdown
+          selectableUserGroups={selectableChildUserGroups}
+          onClickAddExistingUserGroupButton={onClickAddExistingUserGroupButtonHandler}
+          onClickCreateUserGroupButton={showCreateModal}
+        />
+      )}
 
       <UserGroupModal
         userGroup={selectedUserGroup}
@@ -401,6 +424,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
         onClickSubmit={updateChildUserGroup}
         isShow={isUpdateModalShown}
         onHide={hideUpdateModal}
+        isExternalGroup={isExternalGroup}
       />
 
       <UserGroupModal
@@ -420,6 +444,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
         onRemove={removeChildUserGroup}
         onDelete={showDeleteModal}
         userGroupRelations={childUserGroupRelations}
+        isExternalGroup={isExternalGroup}
       />
 
       <UserGroupDeleteModal

+ 35 - 36
apps/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx

@@ -10,15 +10,12 @@ type Props = {
   userGroupRelations: IUserGroupRelationHasIdPopulatedUser[] | undefined,
   onClickRemoveUserBtn: (username: string) => Promise<void>,
   onClickPlusBtn: () => void,
+  isExternalGroup?: boolean
 }
 
 export const UserGroupUserTable = (props: Props): JSX.Element => {
   const { t } = useTranslation('admin');
 
-  const {
-    userGroupRelations, onClickRemoveUserBtn, onClickPlusBtn,
-  } = props;
-
   return (
     <table className="table table-bordered table-user-list">
       <thead>
@@ -30,11 +27,11 @@ export const UserGroupUserTable = (props: Props): JSX.Element => {
           <th>{t('Name')}</th>
           <th style={{ width: '100px' }}>{t('Created')}</th>
           <th style={{ width: '160px' }}>{t('last_login')}</th>
-          <th style={{ width: '70px' }}></th>
+          {!props.isExternalGroup && <th style={{ width: '70px' }}></th>}
         </tr>
       </thead>
       <tbody>
-        {userGroupRelations != null && userGroupRelations.map((relation) => {
+        {props.userGroupRelations != null && props.userGroupRelations.map((relation) => {
           const { relatedUser } = relation;
 
           return (
@@ -48,47 +45,49 @@ export const UserGroupUserTable = (props: Props): JSX.Element => {
               <td>{relatedUser.name}</td>
               <td>{relatedUser.createdAt ? dateFnsFormat(new Date(relatedUser.createdAt), 'yyyy-MM-dd') : ''}</td>
               <td>{relatedUser.lastLoginAt ? dateFnsFormat(new Date(relatedUser.lastLoginAt), 'yyyy-MM-dd HH:mm:ss') : ''}</td>
-              <td>
-                <div className="btn-group admin-user-menu">
-                  <button
-                    type="button"
-                    id={`admin-group-menu-button-${relatedUser._id}`}
-                    className="btn btn-outline-secondary btn-sm dropdown-toggle"
-                    data-toggle="dropdown"
-                  >
-                    <i className="icon-settings"></i>
-                  </button>
-                  <div className="dropdown-menu" role="menu" aria-labelledby={`admin-group-menu-button-${relatedUser._id}`}>
+              {!props.isExternalGroup && (
+                <td>
+                  <div className="btn-group admin-user-menu">
                     <button
-                      className="dropdown-item"
                       type="button"
-                      onClick={() => onClickRemoveUserBtn(relatedUser.username)}
+                      id={`admin-group-menu-button-${relatedUser._id}`}
+                      className="btn btn-outline-secondary btn-sm dropdown-toggle"
+                      data-toggle="dropdown"
                     >
-                      <i className="icon-fw icon-user-unfollow"></i> {t('admin:user_group_management.remove_from_group')}
+                      <i className="icon-settings"></i>
                     </button>
+                    <div className="dropdown-menu" role="menu" aria-labelledby={`admin-group-menu-button-${relatedUser._id}`}>
+                      <button
+                        className="dropdown-item"
+                        type="button"
+                        onClick={() => props.onClickRemoveUserBtn(relatedUser.username)}
+                      >
+                        <i className="icon-fw icon-user-unfollow"></i> {t('admin:user_group_management.remove_from_group')}
+                      </button>
+                    </div>
                   </div>
-                </div>
-              </td>
+                </td>
+              )}
             </tr>
           );
         })}
 
-        <tr>
-          <td></td>
-          <td className="text-center">
-            <button className="btn btn-outline-secondary" type="button" onClick={onClickPlusBtn}>
-              <i className="ti ti-plus"></i>
-            </button>
-          </td>
-          <td></td>
-          <td></td>
-          <td></td>
-          <td></td>
-        </tr>
+        {!props.isExternalGroup && (
+          <tr>
+            <td></td>
+            <td className="text-center">
+              <button className="btn btn-outline-secondary" type="button" onClick={props.onClickPlusBtn}>
+                <i className="ti ti-plus"></i>
+              </button>
+            </td>
+            <td></td>
+            <td></td>
+            <td></td>
+            <td></td>
+          </tr>
+        )}
 
       </tbody>
     </table>
   );
 };
-
-export default UserGroupUserTable;

+ 50 - 0
apps/app/src/components/Admin/UserGroupDetail/use-user-group-resource.ts

@@ -0,0 +1,50 @@
+import {
+  useSWRxAncestorExternalUserGroups,
+  useSWRxChildExternalUserGroupList,
+  useSWRxExternalUserGroup,
+  useSWRxExternalUserGroupRelationList,
+  useSWRxExternalUserGroupRelations,
+} from '~/features/external-user-group/client/stores/external-user-group';
+import {
+  useSWRxAncestorUserGroups,
+  useSWRxChildUserGroupList, useSWRxUserGroup, useSWRxUserGroupRelationList, useSWRxUserGroupRelations,
+} from '~/stores/user-group';
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useUserGroup = (userGroupId: string, isExternalGroup: boolean) => {
+  const userGroupRes = useSWRxUserGroup(isExternalGroup ? null : userGroupId);
+  const externalUserGroupRes = useSWRxExternalUserGroup(isExternalGroup ? userGroupId : null);
+  return isExternalGroup ? externalUserGroupRes : userGroupRes;
+};
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useUserGroupRelations = (userGroupId: string, isExternalGroup: boolean) => {
+  const userGroupRes = useSWRxUserGroupRelations(isExternalGroup ? null : userGroupId);
+  const externalUserGroupRes = useSWRxExternalUserGroupRelations(isExternalGroup ? userGroupId : null);
+  return isExternalGroup ? externalUserGroupRes : userGroupRes;
+};
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useChildUserGroupList = (userGroupId: string, isExternalGroup: boolean) => {
+  const userGroupRes = useSWRxChildUserGroupList(
+    !isExternalGroup ? [userGroupId] : [], true,
+  );
+  const externalUserGroupRes = useSWRxChildExternalUserGroupList(
+    isExternalGroup ? [userGroupId] : [], true,
+  );
+  return isExternalGroup ? externalUserGroupRes : userGroupRes;
+};
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useUserGroupRelationList = (userGroupIds: string[], isExternalGroup: boolean) => {
+  const userGroupRes = useSWRxUserGroupRelationList(isExternalGroup ? null : userGroupIds);
+  const externalUserGroupRes = useSWRxExternalUserGroupRelationList(isExternalGroup ? userGroupIds : null);
+  return isExternalGroup ? externalUserGroupRes : userGroupRes;
+};
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useAncestorUserGroups = (userGroupId: string, isExternalGroup: boolean) => {
+  const userGroupRes = useSWRxAncestorUserGroups(isExternalGroup ? null : userGroupId);
+  const externalUserGroupRes = useSWRxAncestorExternalUserGroups(isExternalGroup ? userGroupId : null);
+  return isExternalGroup ? externalUserGroupRes : userGroupRes;
+};

+ 65 - 31
apps/app/src/components/PageAlert/FixPageGrantAlert.tsx

@@ -1,6 +1,6 @@
 import React, { useEffect, useState, useCallback } from 'react';
 
-import { PageGrant } from '@growi/core';
+import { PageGrant, GroupType } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
@@ -9,7 +9,7 @@ import {
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { IPageGrantData } from '~/interfaces/page';
-import { IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
+import { ApplicableGroup, IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
 import { useCurrentUser } from '~/stores/context';
 import { useSWRxApplicableGrant, useSWRxIsGrantNormalized, useSWRxCurrentPage } from '~/stores/page';
 
@@ -29,7 +29,9 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   } = props;
 
   const [selectedGrant, setSelectedGrant] = useState<PageGrant>(PageGrant.GRANT_RESTRICTED);
-  const [selectedGroup, setSelectedGroup] = useState<{_id: string, name: string} | undefined>(undefined); // TODO: Typescriptize model
+
+  const [isGroupSelectModalShown, setIsGroupSelectModalShown] = useState(false);
+  const [selectedGroups, setSelectedGroups] = useState<ApplicableGroup[]>([]);
 
   // Alert message state
   const [shouldShowModalAlert, setShowModalAlert] = useState<boolean>(false);
@@ -40,14 +42,23 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   useEffect(() => {
     if (isOpen) {
       setSelectedGrant(PageGrant.GRANT_RESTRICTED);
-      setSelectedGroup(undefined);
+      setSelectedGroups([]);
       setShowModalAlert(false);
     }
   }, [isOpen]);
 
+  const groupListItemClickHandler = (group: ApplicableGroup) => {
+    if (selectedGroups.find(g => g.item._id === group.item._id) != null) {
+      setSelectedGroups(selectedGroups.filter(g => g.item._id !== group.item._id));
+    }
+    else {
+      setSelectedGroups([...selectedGroups, group]);
+    }
+  };
+
   const submit = async() => {
     // Validate input values
-    if (selectedGrant === PageGrant.GRANT_USER_GROUP && selectedGroup == null) {
+    if (selectedGrant === PageGrant.GRANT_USER_GROUP && selectedGroups.length === 0) {
       setShowModalAlert(true);
       return;
     }
@@ -57,7 +68,9 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
     try {
       await apiv3Put(`/page/${pageId}/grant`, {
         grant: selectedGrant,
-        grantedGroup: selectedGroup?._id,
+        grantedGroups: selectedGroups.length !== 0 ? selectedGroups.map((g) => {
+          return { item: g.item._id, type: g.type };
+        }) : null,
       });
 
       toastSuccess(t('Successfully updated'));
@@ -86,10 +99,10 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
     }
 
     if (grantData.grant === 5) {
-      if (grantData.grantedGroup == null) {
+      if (grantData.grantedGroups == null || grantData.grantedGroups.length === 0) {
         return t('fix_page_grant.modal.grant_label.isForbidden');
       }
-      return `${t('fix_page_grant.modal.radio_btn.grant_group')}: (${grantData.grantedGroup.name})`;
+      return `${t('fix_page_grant.modal.radio_btn.grant_group')} (${grantData.grantedGroups.map(g => g.name).join(', ')})`;
     }
 
     throw Error('cannot get grant label'); // this error can't be throwed
@@ -178,31 +191,17 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
                   <button
                     type="button"
                     className="btn btn-secondary dropdown-toggle text-right w-100 border-0 shadow-none"
-                    data-toggle="dropdown"
                     disabled={selectedGrant !== PageGrant.GRANT_USER_GROUP} // disable when its radio input is not selected
+                    onClick={() => setIsGroupSelectModalShown(true)}
                   >
                     <span className="float-left ml-2">
                       {
-                        selectedGroup == null
+                        selectedGroups.length === 0
                           ? t('fix_page_grant.modal.select_group_default_text')
-                          : selectedGroup.name
+                          : selectedGroups.map(g => g.item.name).join(', ')
                       }
                     </span>
                   </button>
-                  <div className="dropdown-menu">
-                    {
-                      applicableGroups != null && applicableGroups.map(g => (
-                        <button
-                          key={g._id}
-                          className="dropdown-item"
-                          type="button"
-                          onClick={() => setSelectedGroup(g)}
-                        >
-                          {g.name}
-                        </button>
-                      ))
-                    }
-                  </div>
                 </div>
               </div>
               {
@@ -225,12 +224,47 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   };
 
   return (
-    <Modal size="lg" isOpen={isOpen} toggle={close}>
-      <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
-        { t('fix_page_grant.modal.title') }
-      </ModalHeader>
-      {renderModalBodyAndFooter()}
-    </Modal>
+    <>
+      <Modal size="lg" isOpen={isOpen} toggle={close}>
+        <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
+          { t('fix_page_grant.modal.title') }
+        </ModalHeader>
+        {renderModalBodyAndFooter()}
+      </Modal>
+      {applicableGroups != null && (
+        <Modal
+          isOpen={isGroupSelectModalShown}
+          toggle={() => setIsGroupSelectModalShown(false)}
+        >
+          <ModalHeader tag="h4" toggle={() => setIsGroupSelectModalShown(false)} className="bg-purple text-light">
+            {t('user_group.select_group')}
+          </ModalHeader>
+          <ModalBody>
+            <>
+              { applicableGroups.map((group) => {
+                const groupIsGranted = selectedGroups?.find(g => g.item._id === group.item._id) != null;
+                const activeClass = groupIsGranted ? 'active' : '';
+
+                return (
+                  <button
+                    className={`btn btn-outline-primary w-100 d-flex justify-content-start mb-3 align-items-center p-3 ${activeClass}`}
+                    type="button"
+                    key={group.item._id}
+                    onClick={() => groupListItemClickHandler(group)}
+                  >
+                    <span className="align-middle"><input type="checkbox" checked={groupIsGranted} /></span>
+                    <h5 className="d-inline-block ml-3">{group.item.name}</h5>
+                    {group.type === GroupType.externalUserGroup && <span className="ml-2 badge badge-pill badge-info">{group.item.provider}</span>}
+                    {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
+                  </button>
+                );
+              }) }
+              <button type="button" className="btn btn-primary mt-2 float-right" onClick={() => setIsGroupSelectModalShown(false)}>{t('Done')}</button>
+            </>
+          </ModalBody>
+        </Modal>
+      )}
+    </>
   );
 };
 

+ 10 - 1
apps/app/src/components/PageAlert/PageGrantAlert.tsx

@@ -1,5 +1,6 @@
 import React from 'react';
 
+import { isPopulated } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 
 import { useSWRxCurrentPage } from '~/stores/page';
@@ -13,6 +14,10 @@ export const PageGrantAlert = (): JSX.Element => {
     return <></>;
   }
 
+  const populatedGrantedGroups = () => {
+    return pageData.grantedGroups.filter(group => isPopulated(group.item));
+  };
+
   const renderAlertContent = () => {
     const getGrantLabel = () => {
       if (pageData.grant === 2) {
@@ -32,7 +37,11 @@ export const PageGrantAlert = (): JSX.Element => {
       if (pageData.grant === 5) {
         return (
           <>
-            <i className="icon-fw icon-organization"></i><strong>{pageData.grantedGroup.name}</strong>
+            <i className="icon-fw icon-organization"></i>
+            <strong>{
+              populatedGrantedGroups().map(g => g.item.name).join(', ')
+            }
+            </strong>
           </>
         );
       }

+ 4 - 3
apps/app/src/components/PageEditor.tsx

@@ -5,7 +5,6 @@ import React, {
 import EventEmitter from 'events';
 import nodePath from 'path';
 
-
 import type { IPageHasId } from '@growi/core';
 import { pathUtils } from '@growi/core/dist/utils';
 import detectIndent from 'detect-indent';
@@ -180,13 +179,15 @@ const PageEditor = React.memo((): JSX.Element => {
     if (grantData == null) {
       return;
     }
+    const grantedGroups = grantData.grantedGroups?.map((group) => {
+      return { item: group.id, type: group.type };
+    });
     const optionsToSave = {
       isSlackEnabled: isSlackEnabled ?? false,
       slackChannels: '', // set in save method by opts in SavePageControlls.tsx
       grant: grantData.grant,
       pageTags: pageTags ?? [],
-      grantUserGroupId: grantData.grantedGroup?.id,
-      grantUserGroupName: grantData.grantedGroup?.name,
+      grantUserGroupIds: grantedGroups,
     };
     return optionsToSave;
   }, [grantData, isSlackEnabled, pageTags]);

+ 4 - 2
apps/app/src/components/PageEditorByHackmd.tsx

@@ -98,13 +98,15 @@ export const PageEditorByHackmd = (): JSX.Element => {
     if (grantData == null) {
       return;
     }
+    const grantedGroups = grantData.grantedGroups?.map((group) => {
+      return { item: group.id, type: group.type };
+    });
     const optionsToSave = {
       isSlackEnabled: isSlackEnabled ?? false,
       slackChannels: '', // set in save method by opts in SavePageControlls.tsx
       grant: grantData.grant,
       pageTags: pageTags ?? [],
-      grantUserGroupId: grantData.grantedGroup?.id,
-      grantUserGroupName: grantData.grantedGroup?.name,
+      grantUserGroupIds: grantedGroups,
     };
     return optionsToSave;
   }, [grantData, isSlackEnabled, pageTags]);

+ 3 - 4
apps/app/src/components/SavePageControls.tsx

@@ -18,7 +18,7 @@ import { useSWRxCurrentPage } from '~/stores/page';
 import { useSelectedGrant } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
-import GrantSelector from './SavePageControls/GrantSelector';
+import { GrantSelector } from './SavePageControls/GrantSelector';
 
 
 declare global {
@@ -67,7 +67,7 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
     return null;
   }
 
-  const { grant, grantedGroup } = grantData;
+  const { grant, grantedGroups } = grantData;
 
   const isGrantSelectorDisabledPage = isTopPage(currentPage?.path ?? '') || isUsersProtectedPages(currentPage?.path ?? '');
   const labelSubmitButton = (currentPage != null && !currentPage.isEmpty) ? t('Update') : t('Create');
@@ -82,8 +82,7 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
             <GrantSelector
               grant={grant}
               disabled={isGrantSelectorDisabledPage}
-              grantGroupId={grantedGroup?.id}
-              grantGroupName={grantedGroup?.name}
+              grantedGroups={grantedGroups}
               onUpdateGrant={updateGrantHandler}
             />
           </div>

+ 56 - 44
apps/app/src/components/SavePageControls/GrantSelector.tsx → apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx

@@ -1,7 +1,6 @@
 import React, { useCallback, useState } from 'react';
 
-import { isPopulated } from '@growi/core';
-import type { IUserGroupHasId } from '@growi/core';
+import { isPopulated, GroupType, type IGrantedGroup } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import {
   UncontrolledDropdown,
@@ -12,8 +11,8 @@ import {
 
 import type { IPageGrantData } from '~/interfaces/page';
 import { useCurrentUser } from '~/stores/context';
-import { useSWRxMyUserGroupRelations } from '~/stores/user-group';
 
+import { useMyUserGroups } from './use-my-user-groups';
 
 const AVAILABLE_GRANTS = [
   {
@@ -35,8 +34,11 @@ const AVAILABLE_GRANTS = [
 type Props = {
   disabled?: boolean,
   grant: number,
-  grantGroupId?: string,
-  grantGroupName?: string,
+  grantedGroups?: {
+    id: string,
+    name: string,
+    type: GroupType,
+  }[]
 
   onUpdateGrant?: (grantData: IPageGrantData) => void,
 }
@@ -44,15 +46,14 @@ type Props = {
 /**
  * Page grant select component
  */
-const GrantSelector = (props: Props): JSX.Element => {
+export const GrantSelector = (props: Props): JSX.Element => {
   const { t } = useTranslation();
 
   const {
     disabled,
-    grantGroupName,
+    grantedGroups,
     onUpdateGrant,
     grant: currentGrant,
-    grantGroupId,
   } = props;
 
 
@@ -61,12 +62,12 @@ const GrantSelector = (props: Props): JSX.Element => {
   const { data: currentUser } = useCurrentUser();
 
   const shouldFetch = isSelectGroupModalShown;
-  const { data: myUserGroupRelations, mutate: mutateMyUserGroupRelations } = useSWRxMyUserGroupRelations(shouldFetch);
+  const { data: myUserGroups, update: updateMyUserGroups } = useMyUserGroups(shouldFetch);
 
   const showSelectGroupModal = useCallback(() => {
-    mutateMyUserGroupRelations();
+    updateMyUserGroups();
     setIsSelectGroupModalShown(true);
-  }, [mutateMyUserGroupRelations]);
+  }, [updateMyUserGroups]);
 
   /**
    * change event handler for grant selector
@@ -79,18 +80,23 @@ const GrantSelector = (props: Props): JSX.Element => {
     }
 
     if (onUpdateGrant != null) {
-      onUpdateGrant({ grant, grantedGroup: undefined });
+      onUpdateGrant({ grant, grantedGroups: undefined });
     }
   }, [onUpdateGrant, showSelectGroupModal]);
 
-  const groupListItemClickHandler = useCallback((grantGroup: IUserGroupHasId) => {
-    if (onUpdateGrant != null) {
-      onUpdateGrant({ grant: 5, grantedGroup: { id: grantGroup._id, name: grantGroup.name } });
+  const groupListItemClickHandler = useCallback((grantGroup: IGrantedGroup) => {
+    if (onUpdateGrant != null && isPopulated(grantGroup.item)) {
+      let grantedGroupsCopy = grantedGroups != null ? [...grantedGroups] : [];
+      const grantGroupInfo = { id: grantGroup.item._id, name: grantGroup.item.name, type: grantGroup.type };
+      if (grantedGroupsCopy.find(group => group.id === grantGroupInfo.id) == null) {
+        grantedGroupsCopy.push(grantGroupInfo);
+      }
+      else {
+        grantedGroupsCopy = grantedGroupsCopy.filter(group => group.id !== grantGroupInfo.id);
+      }
+      onUpdateGrant({ grant: 5, grantedGroups: grantedGroupsCopy });
     }
-
-    // hide modal
-    setIsSelectGroupModalShown(false);
-  }, [onUpdateGrant]);
+  }, [onUpdateGrant, grantedGroups]);
 
   /**
    * Render grant selector DOM.
@@ -101,7 +107,7 @@ const GrantSelector = (props: Props): JSX.Element => {
     let dropdownToggleLabelElm;
 
     const dropdownMenuElems = AVAILABLE_GRANTS.map((opt) => {
-      const label = ((opt.grant === 5 && opt.reselectLabel != null) && grantGroupId != null)
+      const label = ((opt.grant === 5 && opt.reselectLabel != null) && grantedGroups != null && grantedGroups.length > 0)
         ? opt.reselectLabel // when grantGroup is selected
         : opt.label;
 
@@ -122,11 +128,19 @@ const GrantSelector = (props: Props): JSX.Element => {
     });
 
     // add specified group option
-    if (grantGroupId != null) {
+    if (grantedGroups != null && grantedGroups.length > 0) {
       const labelElm = (
         <span>
           <i className="icon icon-fw icon-organization"></i>
-          <span className="label">{grantGroupName}</span>
+          <span className="label">
+            {grantedGroups.length > 1
+              ? (
+                <span>
+                  {`${grantedGroups[0].name}... `}
+                  <span className="badge badge-purple">+{grantedGroups.length - 1}</span>
+                </span>
+              ) : grantedGroups[0].name}
+          </span>
         </span>
       );
 
@@ -148,7 +162,7 @@ const GrantSelector = (props: Props): JSX.Element => {
         </UncontrolledDropdown>
       </div>
     );
-  }, [changeGrantHandler, currentGrant, disabled, grantGroupId, grantGroupName, t]);
+  }, [changeGrantHandler, currentGrant, disabled, grantedGroups, t]);
 
   /**
    * Render select grantgroup modal.
@@ -159,7 +173,7 @@ const GrantSelector = (props: Props): JSX.Element => {
     }
 
     // show spinner
-    if (myUserGroupRelations == null) {
+    if (myUserGroups == null) {
       return (
         <div className="my-3 text-center">
           <i className="fa fa-lg fa-spinner fa-pulse mx-auto text-muted"></i>
@@ -167,16 +181,7 @@ const GrantSelector = (props: Props): JSX.Element => {
       );
     }
 
-    // extract IUserGroupHasId
-    const userRelatedGroups: IUserGroupHasId[] = myUserGroupRelations
-      .map((relation) => {
-        // relation.relatedGroup should be populated by server
-        return isPopulated(relation.relatedGroup) ? relation.relatedGroup : undefined;
-      })
-      // exclude undefined elements
-      .filter((elem): elem is IUserGroupHasId => elem != null);
-
-    if (userRelatedGroups.length === 0) {
+    if (myUserGroups.length === 0) {
       return (
         <div>
           <h4>{t('user_group.belonging_to_no_group')}</h4>
@@ -188,19 +193,30 @@ const GrantSelector = (props: Props): JSX.Element => {
     }
 
     return (
-      <div className="list-group">
-        { userRelatedGroups.map((group) => {
+      <>
+        { myUserGroups.map((group) => {
+          const groupIsGranted = grantedGroups?.find(g => g.id === group.item._id) != null;
+          const activeClass = groupIsGranted ? 'active' : '';
+
           return (
-            <button key={group._id} type="button" className="list-group-item list-group-item-action" onClick={() => groupListItemClickHandler(group)}>
-              <h5>{group.name}</h5>
+            <button
+              className={`btn btn-outline-primary w-100 d-flex justify-content-start mb-3 align-items-center p-3 ${activeClass}`}
+              type="button"
+              key={group.item._id}
+              onClick={() => groupListItemClickHandler(group)}
+            >
+              <span className="align-middle"><input type="checkbox" checked={groupIsGranted} /></span>
+              <h5 className="d-inline-block ml-3">{group.item.name}</h5>
+              {group.type === GroupType.externalUserGroup && <span className="ml-2 badge badge-pill badge-info">{group.item.provider}</span>}
               {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
             </button>
           );
         }) }
-      </div>
+        <button type="button" className="btn btn-primary mt-2 float-right" onClick={() => setIsSelectGroupModalShown(false)}>{t('Done')}</button>
+      </>
     );
 
-  }, [currentUser?.admin, groupListItemClickHandler, myUserGroupRelations, shouldFetch, t]);
+  }, [currentUser?.admin, groupListItemClickHandler, myUserGroups, shouldFetch, t, grantedGroups]);
 
   return (
     <>
@@ -209,7 +225,6 @@ const GrantSelector = (props: Props): JSX.Element => {
       {/* render modal */}
       { !disabled && currentUser != null && (
         <Modal
-          className="select-grant-group"
           isOpen={isSelectGroupModalShown}
           toggle={() => setIsSelectGroupModalShown(false)}
         >
@@ -223,7 +238,4 @@ const GrantSelector = (props: Props): JSX.Element => {
       ) }
     </>
   );
-
 };
-
-export default GrantSelector;

+ 1 - 0
apps/app/src/components/SavePageControls/GrantSelector/index.ts

@@ -0,0 +1 @@
+export * from './GrantSelector';

+ 38 - 0
apps/app/src/components/SavePageControls/GrantSelector/use-my-user-groups.ts

@@ -0,0 +1,38 @@
+import { GroupType } from '@growi/core';
+
+import { useSWRxMyExternalUserGroups } from '~/features/external-user-group/client/stores/external-user-group';
+import { useSWRxMyUserGroups } from '~/stores/user-group';
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useMyUserGroups = (shouldFetch: boolean) => {
+  const { data: myUserGroups, mutate: mutateMyUserGroups } = useSWRxMyUserGroups(shouldFetch);
+  const { data: myExternalUserGroups, mutate: mutateMyExternalUserGroups } = useSWRxMyExternalUserGroups(shouldFetch);
+
+  const update = () => {
+    mutateMyUserGroups();
+    mutateMyExternalUserGroups();
+  };
+
+  if (myUserGroups == null || myExternalUserGroups == null) {
+    return { data: null, update };
+  }
+
+  const myUserGroupsData = myUserGroups
+    .map((group) => {
+      return {
+        item: group,
+        type: GroupType.userGroup,
+      };
+    });
+  const myExternalUserGroupsData = myExternalUserGroups
+    .map((group) => {
+      return {
+        item: group,
+        type: GroupType.externalUserGroup,
+      };
+    });
+
+  const data = [...myUserGroupsData, ...myExternalUserGroupsData];
+
+  return { data, update };
+};

+ 1 - 1
apps/app/src/components/Sidebar/PageTree/Item.tsx

@@ -355,7 +355,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
         path: newPagePath,
         body: undefined,
         grant: page.grant,
-        grantUserGroupId: page.grantedGroup,
+        grantUserGroupIds: page.grantedGroups,
       });
 
       mutateChildren();

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

@@ -0,0 +1,181 @@
+import {
+  FC, useCallback, useMemo, useState,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { TabContent, TabPane } from 'reactstrap';
+
+import { apiv3Delete, apiv3Put } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import { UserGroupDeleteModal } from '~/components/Admin/UserGroup/UserGroupDeleteModal';
+import { UserGroupModal } from '~/components/Admin/UserGroup/UserGroupModal';
+import { UserGroupTable } from '~/components/Admin/UserGroup/UserGroupTable';
+import CustomNav from '~/components/CustomNavigation/CustomNav';
+import { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
+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 = () => {
+  const { data: externalUserGroupList, mutate: mutateExternalUserGroups } = useSWRxExternalUserGroupList();
+  const externalUserGroups = externalUserGroupList != null ? externalUserGroupList : [];
+  const externalUserGroupIds = externalUserGroups.map(group => group._id);
+
+  const { data: externalUserGroupRelationList } = useSWRxExternalUserGroupRelationList(externalUserGroupIds);
+  const externalUserGroupRelations = externalUserGroupRelationList != null ? externalUserGroupRelationList : [];
+
+  const { data: childExternalUserGroupsList } = useSWRxChildExternalUserGroupList(externalUserGroupIds);
+  const childExternalUserGroups = childExternalUserGroupsList?.childUserGroups != null ? childExternalUserGroupsList.childUserGroups : [];
+
+  const { data: isAclEnabled } = useIsAclEnabled();
+
+  const [activeTab, setActiveTab] = useState('ldap');
+  const [activeComponents, setActiveComponents] = useState(new Set(['ldap']));
+  const [selectedExternalUserGroup, setSelectedExternalUserGroup] = useState<IExternalUserGroupHasId | undefined>(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
+  const [isUpdateModalShown, setUpdateModalShown] = useState<boolean>(false);
+  const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
+
+  const { t } = useTranslation('admin');
+
+  const showUpdateModal = useCallback((group: IExternalUserGroupHasId) => {
+    setUpdateModalShown(true);
+    setSelectedExternalUserGroup(group);
+  }, [setUpdateModalShown]);
+
+  const hideUpdateModal = useCallback(() => {
+    setUpdateModalShown(false);
+    setSelectedExternalUserGroup(undefined);
+  }, [setUpdateModalShown]);
+
+  const syncUserGroupAndRelations = useCallback(async() => {
+    try {
+      await mutateExternalUserGroups();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [mutateExternalUserGroups]);
+
+  const showDeleteModal = useCallback(async(group: IExternalUserGroupHasId) => {
+    try {
+      await syncUserGroupAndRelations();
+
+      setSelectedExternalUserGroup(group);
+      setDeleteModalShown(true);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [syncUserGroupAndRelations]);
+
+  const hideDeleteModal = useCallback(() => {
+    setSelectedExternalUserGroup(undefined);
+    setDeleteModalShown(false);
+  }, []);
+
+  const updateExternalUserGroup = useCallback(async(userGroupData: IExternalUserGroupHasId) => {
+    try {
+      await apiv3Put(`/external-user-groups/${userGroupData._id}`, {
+        description: userGroupData.description,
+      });
+
+      toastSuccess(t('toaster.update_successed', { target: t('ExternalUserGroup'), ns: 'commons' }));
+
+      await mutateExternalUserGroups();
+
+      hideUpdateModal();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, mutateExternalUserGroups, hideUpdateModal]);
+
+  const deleteExternalUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
+    try {
+      await apiv3Delete(`/external-user-groups/${deleteGroupId}`, {
+        actionName,
+        transferToUserGroupId,
+      });
+
+      // sync
+      await mutateExternalUserGroups();
+
+      hideDeleteModal();
+
+      toastSuccess(`Deleted ${selectedExternalUserGroup?.name} group.`);
+    }
+    catch (err) {
+      toastError(new Error('Unable to delete the groups'));
+    }
+  }, [mutateExternalUserGroups, selectedExternalUserGroup, hideDeleteModal]);
+
+  const switchActiveTab = (selectedTab) => {
+    setActiveTab(selectedTab);
+    setActiveComponents(activeComponents.add(selectedTab));
+  };
+
+  const navTabMapping = useMemo(() => {
+    return {
+      ldap: {
+        Icon: () => <i className="fa fa-sitemap" />,
+        i18n: 'LDAP',
+      },
+      keycloak: {
+        Icon: () => <i className="fa fa-key" />,
+        i18n: 'Keycloak',
+      },
+    };
+  }, []);
+
+  return (
+    <>
+      <h2 className="border-bottom">{t('external_user_group.management')}</h2>
+      <UserGroupTable
+        headerLabel={t('admin:user_group_management.group_list')}
+        userGroups={externalUserGroups}
+        childUserGroups={childExternalUserGroups}
+        isAclEnabled={isAclEnabled ?? false}
+        onEdit={showUpdateModal}
+        onDelete={showDeleteModal}
+        userGroupRelations={externalUserGroupRelations}
+        isExternalGroup
+      />
+
+      <UserGroupModal
+        userGroup={selectedExternalUserGroup}
+        buttonLabel={t('Update')}
+        onClickSubmit={updateExternalUserGroup}
+        isShow={isUpdateModalShown}
+        onHide={hideUpdateModal}
+        isExternalGroup
+      />
+
+      <UserGroupDeleteModal
+        userGroups={externalUserGroups}
+        deleteUserGroup={selectedExternalUserGroup}
+        onDelete={deleteExternalUserGroupById}
+        isShow={isDeleteModalShown}
+        onHide={hideDeleteModal}
+      />
+
+      <CustomNav
+        activeTab={activeTab}
+        navTabMapping={navTabMapping}
+        onNavSelected={switchActiveTab}
+        hideBorderBottom
+        breakpointToSwitchDropdownDown="md"
+      />
+      <TabContent activeTab={activeTab} className="p-5">
+        <TabPane tabId="ldap">
+          {activeComponents.has('ldap') && <LdapGroupManagement />}
+        </TabPane>
+        <TabPane tabId="keycloak">
+          {activeComponents.has('keycloak') && <KeycloakGroupManagement />}
+        </TabPane>
+      </TabContent>
+    </>
+  );
+};

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

@@ -0,0 +1,21 @@
+import { FC, useCallback } from 'react';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { ExternalGroupProviderType } from '~/features/external-user-group/interfaces/external-user-group';
+
+import { KeycloakGroupSyncSettingsForm } from './KeycloakGroupSyncSettingsForm';
+import { SyncExecution } from './SyncExecution';
+
+export const KeycloakGroupManagement: FC = () => {
+
+  const requestSyncAPI = useCallback(async() => {
+    await apiv3Put('/external-user-groups/keycloak/sync');
+  }, []);
+
+  return (
+    <>
+      <KeycloakGroupSyncSettingsForm />
+      <SyncExecution provider={ExternalGroupProviderType.keycloak} requestSyncAPI={requestSyncAPI} />
+    </>
+  );
+};

+ 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: '',
+    keycloakGroupRealm: '',
+    keycloakGroupSyncClientRealm: '',
+    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="keycloakGroupRealm" className="text-left text-md-right col-md-3 col-form-label">
+            {t('external_user_group.keycloak.group_realm')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              required
+              type="text"
+              name="keycloakGroupRealm"
+              id="keycloakGroupRealm"
+              value={formValues.keycloakGroupRealm}
+              onChange={e => setFormValues({ ...formValues, keycloakGroupRealm: e.target.value })}
+            />
+            <p className="form-text text-muted">
+              <small>
+                {t('external_user_group.keycloak.group_realm_detail')} <br />
+              </small>
+            </p>
+          </div>
+        </div>
+        <div className="row form-group">
+          <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>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              required
+              type="text"
+              name="keycloakGroupSyncClientRealm"
+              id="keycloakGroupSyncClientRealm"
+              value={formValues.keycloakGroupSyncClientRealm}
+              onChange={e => setFormValues({ ...formValues, keycloakGroupSyncClientRealm: e.target.value })}
+            />
+            <p className="form-text text-muted">
+              <small>
+                {t('external_user_group.keycloak.group_sync_client_realm_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>
+    </>
+  );
+};

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

@@ -0,0 +1,67 @@
+import {
+  FC, useCallback, useEffect, useState,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
+import { toastError } from '~/client/util/toastr';
+import { ExternalGroupProviderType } from '~/features/external-user-group/interfaces/external-user-group';
+
+import { LdapGroupSyncSettingsForm } from './LdapGroupSyncSettingsForm';
+import { SyncExecution } from './SyncExecution';
+
+export const LdapGroupManagement: FC = () => {
+  const [isUserBind, setIsUserBind] = useState(false);
+  const { t } = useTranslation('admin');
+
+  useEffect(() => {
+    const getIsUserBind = async() => {
+      try {
+        const response = await apiv3Get('/security-setting/');
+        const { ldapAuth } = response.data.securityParams;
+        setIsUserBind(ldapAuth.isUserBind);
+      }
+      catch (e) {
+        toastError(e);
+      }
+    };
+    getIsUserBind();
+  }, []);
+
+  const requestSyncAPI = useCallback(async(e) => {
+    if (isUserBind) {
+      const password = e.target.password?.value;
+      await apiv3Put('/external-user-groups/ldap/sync', { password });
+    }
+    else {
+      await apiv3Put('/external-user-groups/ldap/sync');
+    }
+  }, [isUserBind]);
+
+  const AdditionalForm = (): JSX.Element => {
+    return isUserBind ? (
+      <div className="row form-group">
+        <label htmlFor="ldapGroupSyncPassword" className="text-left text-md-right col-md-3 col-form-label">{t('external_user_group.ldap.password')}</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="password"
+            name="password"
+            id="ldapGroupSyncPassword"
+          />
+          <p className="form-text text-muted">
+            <small>{t('external_user_group.ldap.password_detail')}</small>
+          </p>
+        </div>
+      </div>
+    ) : <></>;
+  };
+
+  return (
+    <>
+      <LdapGroupSyncSettingsForm />
+      <SyncExecution provider={ExternalGroupProviderType.ldap} requestSyncAPI={requestSyncAPI} AdditionalForm={AdditionalForm} />
+    </>
+  );
+};

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

@@ -0,0 +1,247 @@
+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 { useSWRxLdapGroupSyncSettings } from '~/features/external-user-group/client/stores/external-user-group';
+import { LdapGroupMembershipAttributeType, LdapGroupSyncSettings } from '~/features/external-user-group/interfaces/external-user-group';
+
+export const LdapGroupSyncSettingsForm: FC = () => {
+  const { t } = useTranslation('admin');
+
+  const { data: ldapGroupSyncSettings } = useSWRxLdapGroupSyncSettings();
+
+  const [formValues, setFormValues] = useState<LdapGroupSyncSettings>({
+    ldapGroupSearchBase: '',
+    ldapGroupMembershipAttribute: '',
+    ldapGroupMembershipAttributeType: LdapGroupMembershipAttributeType.dn,
+    ldapGroupChildGroupAttribute: '',
+    autoGenerateUserOnLdapGroupSync: false,
+    preserveDeletedLdapGroups: false,
+    ldapGroupNameAttribute: '',
+    ldapGroupDescriptionAttribute: '',
+  });
+
+  useEffect(() => {
+    if (ldapGroupSyncSettings != null) {
+      setFormValues(ldapGroupSyncSettings);
+    }
+  }, [ldapGroupSyncSettings, setFormValues]);
+
+  const submitHandler = useCallback(async(e) => {
+    e.preventDefault();
+    try {
+      await apiv3Put('/external-user-groups/ldap/sync-settings', formValues);
+      toastSuccess(t('external_user_group.ldap.updated_group_sync_settings'));
+    }
+    catch (errs) {
+      toastError(t(errs[0]?.code));
+    }
+  }, [formValues, t]);
+
+  return (
+    <>
+      <h3 className="border-bottom mb-3">{t('external_user_group.ldap.group_sync_settings')}</h3>
+      <form onSubmit={submitHandler}>
+        <div className="row form-group">
+          <label
+            htmlFor="ldapGroupSearchBase"
+            className="text-left text-md-right col-md-3 col-form-label"
+          >
+            {t('external_user_group.ldap.group_search_base_DN')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              type="text"
+              name="ldapGroupSearchBase"
+              id="ldapGroupSearchBase"
+              value={formValues.ldapGroupSearchBase}
+              onChange={e => setFormValues({ ...formValues, ldapGroupSearchBase: e.target.value })}
+            />
+            <p className="form-text text-muted">
+              <small>{t('external_user_group.ldap.group_search_base_dn_detail')}</small>
+            </p>
+          </div>
+        </div>
+        <div className="row form-group">
+          <label htmlFor="ldapGroupMembershipAttribute" className="text-left text-md-right col-md-3 col-form-label">
+            {t('external_user_group.ldap.membership_attribute')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              required
+              type="text"
+              name="ldapGroupMembershipAttribute"
+              id="ldapGroupMembershipAttribute"
+              value={formValues.ldapGroupMembershipAttribute}
+              onChange={e => setFormValues({ ...formValues, ldapGroupMembershipAttribute: e.target.value })}
+            />
+            <p className="form-text text-muted">
+              <small>
+                {t('external_user_group.ldap.membership_attribute_detail')} <br />
+                e.g.) <code>member</code>, <code>memberUid</code>
+              </small>
+            </p>
+          </div>
+        </div>
+        <div className="row form-group">
+          <label htmlFor="ldapGroupMembershipAttributeType" className="text-left text-md-right col-md-3 col-form-label">
+            {t('external_user_group.ldap.membership_attribute_type')}
+          </label>
+          <div className="col-md-6">
+            <select
+              className="form-control"
+              required
+              name="ldapGroupMembershipAttributeType"
+              id="ldapGroupMembershipAttributeType"
+              value={formValues.ldapGroupMembershipAttributeType}
+              onChange={(e) => {
+                if (e.target.value === LdapGroupMembershipAttributeType.dn || e.target.value === LdapGroupMembershipAttributeType.uid) {
+                  setFormValues({ ...formValues, ldapGroupMembershipAttributeType: e.target.value });
+                }
+              }}
+            >
+              <option value="DN">DN</option>
+              <option value="UID">UID</option>
+            </select>
+            <p className="form-text text-muted">
+              <small>
+                {t('external_user_group.ldap.membership_attribute_type_detail')}
+              </small>
+            </p>
+          </div>
+        </div>
+        <div className="row form-group">
+          <label htmlFor="ldapGroupChildGroupAttribute" className="text-left text-md-right col-md-3 col-form-label">
+            {t('external_user_group.ldap.child_group_attribute')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              required
+              type="text"
+              name="ldapGroupChildGroupAttribute"
+              id="ldapGroupChildGroupAttribute"
+              value={formValues.ldapGroupChildGroupAttribute}
+              onChange={e => setFormValues({ ...formValues, ldapGroupChildGroupAttribute: e.target.value })}
+            />
+            <p className="form-text text-muted">
+              <small>
+                {t('external_user_group.ldap.child_group_attribute_detail')}<br />
+                e.g.) <code>member</code>
+              </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="autoGenerateUserOnLdapGroupSync"
+                id="autoGenerateUserOnLdapGroupSync"
+                checked={formValues.autoGenerateUserOnLdapGroupSync}
+                onChange={() => setFormValues({ ...formValues, autoGenerateUserOnLdapGroupSync: !formValues.autoGenerateUserOnLdapGroupSync })}
+              />
+              <label
+                className="custom-control-label"
+                htmlFor="autoGenerateUserOnLdapGroupSync"
+              >
+                {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.ldap.preserve_deleted_ldap_groups')} */}
+          </label>
+          <div className="col-md-6">
+            <div className="custom-control custom-checkbox custom-checkbox-info">
+              <input
+                type="checkbox"
+                className="custom-control-input"
+                name="preserveDeletedLdapGroups"
+                id="preserveDeletedLdapGroups"
+                checked={formValues.preserveDeletedLdapGroups}
+                onChange={() => setFormValues({ ...formValues, preserveDeletedLdapGroups: !formValues.preserveDeletedLdapGroups })}
+              />
+              <label
+                className="custom-control-label"
+                htmlFor="preserveDeletedLdapGroups"
+              >
+                {t('external_user_group.ldap.preserve_deleted_ldap_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="ldapGroupNameAttribute" className="text-left text-md-right col-md-3 col-form-label">{t('Name')}</label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              type="text"
+              name="ldapGroupNameAttribute"
+              id="ldapGroupNameAttribute"
+              value={formValues.ldapGroupNameAttribute}
+              onChange={e => setFormValues({ ...formValues, ldapGroupNameAttribute: e.target.value })}
+              placeholder="Default: cn"
+            />
+            <p className="form-text text-muted">
+              <small>
+                {t('external_user_group.ldap.name_mapper_detail')}
+              </small>
+            </p>
+          </div>
+        </div>
+        <div className="row form-group">
+          <label htmlFor="ldapGroupDescriptionAttribute" 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="ldapGroupDescriptionAttribute"
+              id="ldapGroupDescriptionAttribute"
+              value={formValues.ldapGroupDescriptionAttribute || ''}
+              onChange={e => setFormValues({ ...formValues, ldapGroupDescriptionAttribute: 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>
+    </>
+  );
+};

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

@@ -0,0 +1,171 @@
+import {
+  FC, useCallback, useEffect, useState,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { Modal, ModalHeader, ModalBody } from 'reactstrap';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import LabeledProgressBar from '~/components/Admin/Common/LabeledProgressBar';
+import { ExternalGroupProviderType } from '~/features/external-user-group/interfaces/external-user-group';
+import { SocketEventName } from '~/interfaces/websocket';
+import { useAdminSocket } from '~/stores/socket-io';
+
+import { useSWRxExternalUserGroupList } from '../../stores/external-user-group';
+
+type SyncExecutionProps = {
+  provider: ExternalGroupProviderType
+  requestSyncAPI: (e?: React.FormEvent<HTMLFormElement>) => Promise<void>
+  AdditionalForm?: FC
+}
+
+enum SyncStatus {
+  beforeSync,
+  syncExecuting,
+  syncCompleted,
+  syncFailed,
+}
+
+export const SyncExecution = ({
+  provider,
+  requestSyncAPI,
+  AdditionalForm = () => <></>,
+}: SyncExecutionProps): JSX.Element => {
+  const { t } = useTranslation('admin');
+  const { data: socket } = useAdminSocket();
+  const { mutate: mutateExternalUserGroups } = useSWRxExternalUserGroupList();
+  const [syncStatus, setSyncStatus] = useState<SyncStatus>(SyncStatus.beforeSync);
+  const [progress, setProgress] = useState({
+    total: 0,
+    current: 0,
+  });
+  const [isAlertModalOpen, setIsAlertModalOpen] = useState(false);
+  // value to propagate the submit event of form to submit confirm modal
+  const [currentSubmitEvent, setCurrentSubmitEvent] = useState<React.FormEvent<HTMLFormElement>>();
+
+  useEffect(() => {
+    if (socket == null) return;
+
+    const eventName = SocketEventName.externalUserGroup[provider];
+
+    socket.on(eventName.GroupSyncProgress, (data) => {
+      setSyncStatus(SyncStatus.syncExecuting);
+      setProgress({
+        total: data.totalCount,
+        current: data.count,
+      });
+    });
+
+    socket.on(eventName.GroupSyncCompleted, () => {
+      setSyncStatus(SyncStatus.syncCompleted);
+      mutateExternalUserGroups();
+      toastSuccess(t('external_user_group.sync_succeeded'));
+    });
+
+    socket.on(eventName.GroupSyncFailed, () => {
+      setSyncStatus(SyncStatus.syncFailed);
+      mutateExternalUserGroups();
+      toastError(t('external_user_group.sync_failed'));
+    });
+
+    return () => {
+      socket.off(eventName.GroupSyncProgress);
+      socket.off(eventName.GroupSyncCompleted);
+      socket.off(eventName.GroupSyncFailed);
+    };
+  }, [socket, mutateExternalUserGroups, t, provider]);
+
+  // get sync status on load, since next socket data may take a while
+  useEffect(() => {
+    const getSyncStatus = async() => {
+      const res = await apiv3Get(`/external-user-groups/${provider}/sync-status`);
+      if (res.data.isExecutingSync) {
+        setSyncStatus(SyncStatus.syncExecuting);
+        setProgress({ total: res.data.totalCount, current: res.data.count });
+      }
+    };
+    getSyncStatus();
+  }, [provider]);
+
+  const onSyncBtnClick = (e: React.FormEvent<HTMLFormElement>) => {
+    e.preventDefault();
+    setCurrentSubmitEvent(e);
+    setIsAlertModalOpen(true);
+  };
+
+  const onSyncExecConfirmBtnClick = useCallback(async() => {
+    setIsAlertModalOpen(false);
+    try {
+      // set sync status before requesting to API, so that setting to syncFailed does not get overwritten
+      setSyncStatus(SyncStatus.syncExecuting);
+      setProgress({ total: 0, current: 0 });
+      await requestSyncAPI(currentSubmitEvent);
+    }
+    catch (errs) {
+      setSyncStatus(SyncStatus.syncFailed);
+      toastError(t(errs[0]?.code));
+    }
+  }, [t, requestSyncAPI, currentSubmitEvent]);
+
+  const renderProgressBar = () => {
+    if (syncStatus === SyncStatus.beforeSync) return null;
+
+    let header;
+    if (syncStatus === SyncStatus.syncExecuting) {
+      header = 'Processing..';
+    }
+    else if (syncStatus === SyncStatus.syncCompleted) {
+      header = 'Completed';
+    }
+    else {
+      header = 'Failed';
+    }
+
+    return (
+      <LabeledProgressBar
+        header={header}
+        currentCount={progress.current}
+        totalCount={progress.total}
+      />
+    );
+  };
+
+  return (
+    <>
+      <h3 className="border-bottom mb-3">{t('external_user_group.execute_sync')}</h3>
+      <div className="row">
+        <div className="col-md-3"></div>
+        <div className="col-md-9">
+          {renderProgressBar()}
+        </div>
+      </div>
+      <form onSubmit={onSyncBtnClick}>
+        <AdditionalForm />
+        <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>
+
+      <Modal
+        isOpen={isAlertModalOpen}
+        toggle={() => setIsAlertModalOpen(false)}
+      >
+        <ModalHeader tag="h4" toggle={() => setIsAlertModalOpen(false)} className="bg-purple text-light">
+          <i className="icon-fw icon-exclamation align-middle"></i>
+          <span className="align-middle">{t('external_user_group.confirmation_before_sync')}</span>
+        </ModalHeader>
+        <ModalBody>
+          <ul>
+            <li>{t('external_user_group.execution_time_warning')}</li>
+            <li>{t('external_user_group.parallel_sync_forbidden')}</li>
+          </ul>
+          <div className="text-center">
+            <button className="btn btn-primary" type="button" onClick={onSyncExecConfirmBtnClick}>{t('Execute')}</button>
+          </div>
+        </ModalBody>
+      </Modal>
+    </>
+  );
+};

+ 106 - 0
apps/app/src/features/external-user-group/client/stores/external-user-group.ts

@@ -0,0 +1,106 @@
+import { type SWRResponseWithUtils, withUtils } from '@growi/core/dist/swr';
+import useSWR, { SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
+
+import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
+import {
+  IExternalUserGroupHasId, IExternalUserGroupRelationHasId, KeycloakGroupSyncSettings, LdapGroupSyncSettings,
+} from '~/features/external-user-group/interfaces/external-user-group';
+import {
+  ChildUserGroupListResult, IUserGroupRelationHasIdPopulatedUser, UserGroupListResult, UserGroupRelationListResult,
+} from '~/interfaces/user-group-response';
+
+export const useSWRxLdapGroupSyncSettings = (): SWRResponse<LdapGroupSyncSettings, Error> => {
+  return useSWR(
+    '/external-user-groups/ldap/sync-settings',
+    endpoint => apiv3Get(endpoint).then((response) => {
+      return response.data;
+    }),
+  );
+};
+
+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,
+    endpoint => apiv3Get<UserGroupListResult<IExternalUserGroupHasId>>(endpoint).then(result => result.data.userGroups),
+  );
+};
+
+export const useSWRxExternalUserGroup = (groupId: string | null): SWRResponse<IExternalUserGroupHasId, Error> => {
+  return useSWRImmutable(
+    groupId != null ? `/external-user-groups/${groupId}` : null,
+    endpoint => apiv3Get(endpoint).then(result => result.data.userGroup),
+  );
+};
+
+export const useSWRxExternalUserGroupList = (initialData?: IExternalUserGroupHasId[]): SWRResponse<IExternalUserGroupHasId[], Error> => {
+  return useSWRImmutable(
+    '/external-user-groups',
+    endpoint => apiv3Get(endpoint, { pagination: false }).then(result => result.data.userGroups),
+    {
+      fallbackData: initialData,
+    },
+  );
+};
+
+type ChildExternalUserGroupListUtils = {
+  updateChild(childGroupData: IExternalUserGroupHasId): Promise<void>, // update one child and refresh list
+}
+export const useSWRxChildExternalUserGroupList = (
+    parentIds?: string[], includeGrandChildren?: boolean,
+): SWRResponseWithUtils<ChildExternalUserGroupListUtils, ChildUserGroupListResult<IExternalUserGroupHasId>, Error> => {
+  const shouldFetch = parentIds != null && parentIds.length > 0;
+
+  const swrResponse = useSWRImmutable(
+    shouldFetch ? ['/external-user-groups/children', parentIds, includeGrandChildren] : null,
+    ([endpoint, parentIds, includeGrandChildren]) => apiv3Get<ChildUserGroupListResult<IExternalUserGroupHasId>>(
+      endpoint, { parentIds, includeGrandChildren },
+    ).then((result => result.data)),
+  );
+
+  const updateChild = async(childGroupData: IExternalUserGroupHasId) => {
+    await apiv3Put(`/external-user-groups/${childGroupData._id}`, {
+      description: childGroupData.description,
+    });
+    swrResponse.mutate();
+  };
+
+  return withUtils(swrResponse, { updateChild });
+};
+
+export const useSWRxExternalUserGroupRelations = (groupId: string | null): SWRResponse<IUserGroupRelationHasIdPopulatedUser[], Error> => {
+  return useSWRImmutable(
+    groupId != null ? `/external-user-groups/${groupId}/external-user-group-relations` : null,
+    endpoint => apiv3Get(endpoint).then(result => result.data.userGroupRelations),
+  );
+};
+
+export const useSWRxExternalUserGroupRelationList = (
+    groupIds: string[] | null, childGroupIds?: string[], initialData?: IExternalUserGroupRelationHasId[],
+): SWRResponse<IExternalUserGroupRelationHasId[], Error> => {
+  return useSWRImmutable(
+    groupIds != null ? ['/external-user-group-relations', groupIds, childGroupIds] : null,
+    ([endpoint, groupIds, childGroupIds]) => apiv3Get<UserGroupRelationListResult<IExternalUserGroupRelationHasId>>(
+      endpoint, { groupIds, childGroupIds },
+    ).then(result => result.data.userGroupRelations),
+    {
+      fallbackData: initialData,
+    },
+  );
+};
+
+export const useSWRxAncestorExternalUserGroups = (groupId: string | null): SWRResponse<IExternalUserGroupHasId[], Error> => {
+  return useSWRImmutable(
+    groupId != null ? ['/external-user-groups/ancestors', groupId] : null,
+    ([endpoint, groupId]) => apiv3Get(endpoint, { groupId }).then(result => result.data.ancestorUserGroups),
+  );
+};

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

@@ -0,0 +1,62 @@
+import type {
+  HasObjectId, IUserGroupRelation, Ref, IUserGroup,
+} from '@growi/core';
+
+
+export const ExternalGroupProviderType = { ldap: 'ldap', keycloak: 'keycloak' } as const;
+export type ExternalGroupProviderType = typeof ExternalGroupProviderType[keyof typeof ExternalGroupProviderType];
+
+export interface IExternalUserGroup extends Omit<IUserGroup, 'parent'> {
+  parent: Ref<IExternalUserGroup> | null
+  externalId: string // identifier used in external app/server
+  provider: ExternalGroupProviderType
+}
+
+export type IExternalUserGroupHasId = IExternalUserGroup & HasObjectId;
+
+export interface IExternalUserGroupRelation extends Omit<IUserGroupRelation, 'relatedGroup'> {
+  relatedGroup: Ref<IExternalUserGroup>
+}
+
+export type IExternalUserGroupRelationHasId = IExternalUserGroupRelation & HasObjectId;
+
+export const LdapGroupMembershipAttributeType = { dn: 'DN', uid: 'UID' } as const;
+type LdapGroupMembershipAttributeType = typeof LdapGroupMembershipAttributeType[keyof typeof LdapGroupMembershipAttributeType];
+
+export interface LdapGroupSyncSettings {
+  ldapGroupSearchBase: string
+  ldapGroupMembershipAttribute: string
+  ldapGroupMembershipAttributeType: LdapGroupMembershipAttributeType
+  ldapGroupChildGroupAttribute: string
+  autoGenerateUserOnLdapGroupSync: boolean
+  preserveDeletedLdapGroups: boolean
+  ldapGroupNameAttribute: string
+  ldapGroupDescriptionAttribute?: string
+}
+
+export interface KeycloakGroupSyncSettings {
+  keycloakHost: string
+  keycloakGroupRealm: string
+  keycloakGroupSyncClientRealm: string
+  keycloakGroupSyncClientID: string
+  keycloakGroupSyncClientSecret: string
+  autoGenerateUserOnKeycloakGroupSync: boolean
+  preserveDeletedKeycloakGroups: boolean
+  keycloakGroupDescriptionAttribute?: string
+}
+
+export type ExternalUserInfo = {
+  id: string, // external user id
+  username: string,
+  name?: string,
+  email?: string,
+}
+
+// Data structure to express the tree structure of external groups, before converting to ExternalUserGroup model
+export interface ExternalUserGroupTreeNode {
+  id: string // external group id
+  userInfos: ExternalUserInfo[]
+  childGroupNodes: ExternalUserGroupTreeNode[]
+  name: string
+  description?: string
+}

+ 126 - 0
apps/app/src/features/external-user-group/server/models/external-user-group-relation.integ.ts

@@ -0,0 +1,126 @@
+import mongoose from 'mongoose';
+
+import ExternalUserGroup from './external-user-group';
+import ExternalUserGroupRelation from './external-user-group-relation';
+
+// TODO: use actual user model after ~/server/models/user.js becomes importable in vitest
+// ref: https://github.com/vitest-dev/vitest/issues/846
+const userSchema = new mongoose.Schema({
+  name: { type: String },
+  username: { type: String, required: true, unique: true },
+  email: { type: String, unique: true, sparse: true },
+}, {
+  timestamps: true,
+});
+const User = mongoose.model('User', userSchema);
+
+describe('ExternalUserGroupRelation model', () => {
+  let user1;
+  const userId1 = new mongoose.Types.ObjectId();
+
+  let user2;
+  const userId2 = new mongoose.Types.ObjectId();
+
+  const groupId1 = new mongoose.Types.ObjectId();
+  const groupId2 = new mongoose.Types.ObjectId();
+  const groupId3 = new mongoose.Types.ObjectId();
+
+  beforeAll(async() => {
+    user1 = await User.create({
+      _id: userId1, name: 'user1', username: 'user1', email: 'user1@example.com',
+    });
+
+    user2 = await User.create({
+      _id: userId2, name: 'user2', username: 'user2', email: 'user2@example.com',
+    });
+
+    await ExternalUserGroup.insertMany([
+      {
+        _id: groupId1, name: 'test group 1', externalId: 'testExternalId', provider: 'testProvider',
+      },
+      {
+        _id: groupId2, name: 'test group 2', externalId: 'testExternalId2', provider: 'testProvider',
+      },
+      {
+        _id: groupId3, name: 'test group 3', externalId: 'testExternalId3', provider: 'testProvider',
+      },
+    ]);
+  });
+
+  afterEach(async() => {
+    await ExternalUserGroupRelation.deleteMany();
+  });
+
+  describe('createRelations', () => {
+    it('creates relation for user', async() => {
+      await ExternalUserGroupRelation.createRelations([groupId1, groupId2], user1);
+      const relations = await ExternalUserGroupRelation.find();
+      const idCombinations = relations.map((relation) => {
+        return [relation.relatedGroup, relation.relatedUser];
+      });
+      expect(idCombinations).toStrictEqual([[groupId1, userId1], [groupId2, userId1]]);
+    });
+  });
+
+  describe('removeAllInvalidRelations', () => {
+    beforeAll(async() => {
+      const nonExistentGroupId1 = new mongoose.Types.ObjectId();
+      const nonExistentGroupId2 = new mongoose.Types.ObjectId();
+      await ExternalUserGroupRelation.createRelations([nonExistentGroupId1, nonExistentGroupId2], user1);
+    });
+
+    it('removes invalid relations', async() => {
+      const relationsBeforeRemoval = await ExternalUserGroupRelation.find();
+      expect(relationsBeforeRemoval.length).not.toBe(0);
+
+      await ExternalUserGroupRelation.removeAllInvalidRelations();
+
+      const relationsAfterRemoval = await ExternalUserGroupRelation.find();
+      expect(relationsAfterRemoval.length).toBe(0);
+    });
+  });
+
+  describe('findAllUserIdsForUserGroups', () => {
+    beforeAll(async() => {
+      await ExternalUserGroupRelation.createRelations([groupId1, groupId2], user1);
+      await ExternalUserGroupRelation.create({ relatedGroup: groupId3, relatedUser: user2._id });
+    });
+
+    it('finds all unique user ids for specified user groups', async() => {
+      const userIds = await ExternalUserGroupRelation.findAllUserIdsForUserGroups([groupId1, groupId2, groupId3]);
+      expect(userIds).toStrictEqual([userId1.toString(), user2._id.toString()]);
+    });
+  });
+
+  describe('findAllUserGroupIdsRelatedToUser', () => {
+    beforeAll(async() => {
+      await ExternalUserGroupRelation.createRelations([groupId1, groupId2], user1);
+      await ExternalUserGroupRelation.create({ relatedGroup: groupId3, relatedUser: user2._id });
+    });
+
+    it('finds all group ids related to user', async() => {
+      const groupIds = await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user1);
+      expect(groupIds).toStrictEqual([groupId1, groupId2]);
+
+      const groupIds2 = await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user2);
+      expect(groupIds2).toStrictEqual([groupId3]);
+    });
+  });
+
+  describe('findAllGroupsForUser', () => {
+    beforeAll(async() => {
+      await ExternalUserGroupRelation.createRelations([groupId1, groupId2], user1);
+      await ExternalUserGroupRelation.create({ relatedGroup: groupId3, relatedUser: user2._id });
+    });
+
+    it('finds all groups related to user', async() => {
+      const groups = await ExternalUserGroupRelation.findAllGroupsForUser(user1);
+      const groupIds = groups.map(group => group._id);
+      expect(groupIds).toStrictEqual([groupId1, groupId2]);
+
+      const groups2 = await ExternalUserGroupRelation.findAllGroupsForUser(user2);
+      const groupIds2 = groups2.map(group => group._id);
+      expect(groupIds2).toStrictEqual([groupId3]);
+    });
+  });
+});

+ 54 - 0
apps/app/src/features/external-user-group/server/models/external-user-group-relation.ts

@@ -0,0 +1,54 @@
+import { Schema, Model, Document } from 'mongoose';
+
+import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+import UserGroupRelation from '~/server/models/user-group-relation';
+
+import { getOrCreateModel } from '../../../../server/util/mongoose-utils';
+import { IExternalUserGroupRelation } from '../../interfaces/external-user-group';
+
+import { ExternalUserGroupDocument } from './external-user-group';
+
+export interface ExternalUserGroupRelationDocument extends IExternalUserGroupRelation, Document {}
+
+export interface ExternalUserGroupRelationModel extends Model<ExternalUserGroupRelationDocument> {
+  [x:string]: any, // for old methods
+
+  PAGE_ITEMS: 50,
+
+  removeAllByUserGroups: (groupsToDelete: ExternalUserGroupDocument[]) => Promise<any>,
+
+  findAllUserIdsForUserGroups: (userGroupIds: ObjectIdLike[]) => Promise<string[]>,
+
+  findGroupsWithDescendantsByGroupAndUser: (group: ExternalUserGroupDocument, user) => Promise<ExternalUserGroupDocument[]>,
+
+  countByGroupIdsAndUser: (userGroupIds: ObjectIdLike[], userData) => Promise<number>
+
+  findAllGroupsForUser: (user) => Promise<ExternalUserGroupDocument[]>
+}
+
+const schema = new Schema<ExternalUserGroupRelationDocument, ExternalUserGroupRelationModel>({
+  relatedGroup: { type: Schema.Types.ObjectId, ref: 'ExternalUserGroup', required: true },
+  relatedUser: { type: Schema.Types.ObjectId, ref: 'User', required: true },
+}, {
+  timestamps: { createdAt: true, updatedAt: false },
+});
+
+schema.statics.createRelations = UserGroupRelation.createRelations;
+
+schema.statics.removeAllByUserGroups = UserGroupRelation.removeAllByUserGroups;
+
+schema.statics.findAllRelation = UserGroupRelation.findAllRelation;
+
+schema.statics.removeAllInvalidRelations = UserGroupRelation.removeAllInvalidRelations;
+
+schema.statics.findGroupsWithDescendantsByGroupAndUser = UserGroupRelation.findGroupsWithDescendantsByGroupAndUser;
+
+schema.statics.countByGroupIdsAndUser = UserGroupRelation.countByGroupIdsAndUser;
+
+schema.statics.findAllUserIdsForUserGroups = UserGroupRelation.findAllUserIdsForUserGroups;
+
+schema.statics.findAllUserGroupIdsRelatedToUser = UserGroupRelation.findAllUserGroupIdsRelatedToUser;
+
+schema.statics.findAllGroupsForUser = UserGroupRelation.findAllGroupsForUser;
+
+export default getOrCreateModel<ExternalUserGroupRelationDocument, ExternalUserGroupRelationModel>('ExternalUserGroupRelation', schema);

+ 73 - 0
apps/app/src/features/external-user-group/server/models/external-user-group.integ.ts

@@ -0,0 +1,73 @@
+import mongoose from 'mongoose';
+
+import ExternalUserGroup from './external-user-group';
+
+describe('ExternalUserGroup model', () => {
+  describe('findAndUpdateOrCreateGroup', () => {
+    const groupId = new mongoose.Types.ObjectId();
+    beforeAll(async() => {
+      await ExternalUserGroup.create({
+        _id: groupId, name: 'test group', externalId: 'testExternalId', provider: 'testProvider',
+      });
+    });
+
+    it('finds and updates existing group', async() => {
+      const group = await ExternalUserGroup.findAndUpdateOrCreateGroup('edited test group', 'testExternalId', 'testProvider');
+      expect(group.id).toBe(groupId.toString());
+      expect(group.name).toBe('edited test group');
+    });
+
+    it('creates new group with parent', async() => {
+      expect(await ExternalUserGroup.count()).toBe(1);
+      const newGroup = await ExternalUserGroup.findAndUpdateOrCreateGroup(
+        'new group', 'nonExistentExternalId', 'testProvider', undefined, groupId.toString(),
+      );
+      expect(await ExternalUserGroup.count()).toBe(2);
+      expect(newGroup.parent.toString()).toBe(groupId.toString());
+    });
+
+    it('throws error when parent does not exist', async() => {
+      try {
+        await ExternalUserGroup.findAndUpdateOrCreateGroup(
+          'new group', 'nonExistentExternalId', 'testProvider', undefined, new mongoose.Types.ObjectId(),
+        );
+      }
+      catch (e) {
+        expect(e.message).toBe('Parent does not exist.');
+      }
+    });
+  });
+
+  describe('findGroupsWithAncestorsRecursively', () => {
+    const childGroupId = new mongoose.Types.ObjectId();
+    const parentGroupId = new mongoose.Types.ObjectId();
+    const grandParentGroupId = new mongoose.Types.ObjectId();
+
+    beforeAll(async() => {
+      await ExternalUserGroup.deleteMany();
+      await ExternalUserGroup.create({
+        _id: grandParentGroupId, name: 'grand parent group', externalId: 'grandParentExternalId', provider: 'testProvider',
+      });
+      await ExternalUserGroup.create({
+        _id: parentGroupId, name: 'parent group', externalId: 'parentExternalId', provider: 'testProvider', parent: grandParentGroupId,
+      });
+      await ExternalUserGroup.create({
+        _id: childGroupId, name: 'child group', externalId: 'childExternalId', provider: 'testProvider', parent: parentGroupId,
+      });
+    });
+
+    it('finds ancestors for child', async() => {
+      const childGroup = await ExternalUserGroup.findById(childGroupId);
+      const groups = await ExternalUserGroup.findGroupsWithAncestorsRecursively(childGroup);
+      const groupIds = groups.map(group => group.id);
+      expect(groupIds).toStrictEqual([grandParentGroupId.toString(), parentGroupId.toString(), childGroupId.toString()]);
+    });
+
+    it('finds ancestors for child, excluding child', async() => {
+      const childGroup = await ExternalUserGroup.findById(childGroupId);
+      const groups = await ExternalUserGroup.findGroupsWithAncestorsRecursively(childGroup, []);
+      const groupIds = groups.map(group => group.id);
+      expect(groupIds).toStrictEqual([grandParentGroupId.toString(), parentGroupId.toString()]);
+    });
+  });
+});

+ 64 - 0
apps/app/src/features/external-user-group/server/models/external-user-group.ts

@@ -0,0 +1,64 @@
+import { Schema, Model, Document } from 'mongoose';
+import mongoosePaginate from 'mongoose-paginate-v2';
+
+import { IExternalUserGroup } from '~/features/external-user-group/interfaces/external-user-group';
+import UserGroup from '~/server/models/user-group';
+import { getOrCreateModel } from '~/server/util/mongoose-utils';
+
+export interface ExternalUserGroupDocument extends IExternalUserGroup, Document {}
+
+export interface ExternalUserGroupModel extends Model<ExternalUserGroupDocument> {
+  [x:string]: any, // for old methods
+
+  PAGE_ITEMS: 10,
+
+  findGroupsWithDescendantsRecursively: (groups, descendants?) => any,
+}
+
+const schema = new Schema<ExternalUserGroupDocument, ExternalUserGroupModel>({
+  name: { type: String, required: true },
+  parent: { type: Schema.Types.ObjectId, ref: 'ExternalUserGroup', index: true },
+  description: { type: String, default: '' },
+  externalId: { type: String, required: true, unique: true },
+  provider: { type: String, required: true },
+}, {
+  timestamps: true,
+});
+schema.plugin(mongoosePaginate);
+// group name should be unique for each provider
+schema.index({ name: 1, provider: 1 }, { unique: true });
+
+/**
+ * Find group that has specified externalId and update, or create one if it doesn't exist.
+ * @param name ExternalUserGroup name
+ * @param name ExternalUserGroup externalId
+ * @param name ExternalUserGroup provider
+ * @param name ExternalUserGroup description
+ * @param name ExternalUserGroup parentId
+ * @returns ExternalUserGroupDocument[]
+ */
+schema.statics.findAndUpdateOrCreateGroup = async function(name: string, externalId: string, provider: string, description?: string, parentId?: string) {
+  let parent: ExternalUserGroupDocument | null = null;
+  if (parentId != null) {
+    parent = await this.findOne({ _id: parentId });
+    if (parent == null) {
+      throw Error('Parent does not exist.');
+    }
+  }
+
+  return this.findOneAndUpdate({ externalId }, {
+    name, description, provider, parent,
+  }, { upsert: true, new: true });
+};
+
+schema.statics.findWithPagination = UserGroup.findWithPagination;
+
+schema.statics.findChildrenByParentIds = UserGroup.findChildrenByParentIds;
+
+schema.statics.findGroupsWithAncestorsRecursively = UserGroup.findGroupsWithAncestorsRecursively;
+
+schema.statics.findGroupsWithDescendantsRecursively = UserGroup.findGroupsWithDescendantsRecursively;
+
+schema.statics.findGroupsWithDescendantsById = UserGroup.findGroupsWithDescendantsById;
+
+export default getOrCreateModel<ExternalUserGroupDocument, ExternalUserGroupModel>('ExternalUserGroup', schema);

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

@@ -0,0 +1,55 @@
+import { ErrorV3 } from '@growi/core/dist/models';
+import { Router, Request } from 'express';
+
+import { IExternalUserGroupRelationHasId } from '~/features/external-user-group/interfaces/external-user-group';
+import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
+import Crowi from '~/server/crowi';
+import loggerFactory from '~/utils/logger';
+
+import { ApiV3Response } from '../../../../../server/routes/apiv3/interfaces/apiv3-response';
+
+const logger = loggerFactory('growi:routes:apiv3:user-group-relation'); // eslint-disable-line no-unused-vars
+
+const express = require('express');
+const { query } = require('express-validator');
+
+const { serializeUserGroupRelationSecurely } = require('~/server/models/serializers/user-group-relation-serializer');
+
+const router = express.Router();
+
+module.exports = (crowi: Crowi): Router => {
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+  const adminRequired = require('~/server/middlewares/admin-required')(crowi);
+
+  const validators = {
+    list: [
+      query('groupIds').isArray(),
+      query('childGroupIds').optional().isArray(),
+    ],
+  };
+
+  router.get('/', loginRequiredStrictly, adminRequired, validators.list, async(req: Request, res: ApiV3Response) => {
+    const { query } = req;
+
+    try {
+      const relations = await ExternalUserGroupRelation.find({ relatedGroup: { $in: query.groupIds } }).populate('relatedUser');
+
+      let relationsOfChildGroups: IExternalUserGroupRelationHasId[] | null = null;
+      if (Array.isArray(query.childGroupIds)) {
+        const _relationsOfChildGroups = await ExternalUserGroupRelation.find({ relatedGroup: { $in: query.childGroupIds } }).populate('relatedUser');
+        relationsOfChildGroups = _relationsOfChildGroups.map(relation => serializeUserGroupRelationSecurely(relation)); // serialize
+      }
+
+      const serialized = relations.map(relation => serializeUserGroupRelationSecurely(relation));
+
+      return res.apiv3({ userGroupRelations: serialized, relationsOfChildGroups });
+    }
+    catch (err) {
+      const msg = 'Error occurred in fetching user group relations';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg));
+    }
+  });
+
+  return router;
+};

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

@@ -0,0 +1,393 @@
+import { GroupType } from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import { Router, Request } from 'express';
+import {
+  body, param, query, validationResult,
+} from 'express-validator';
+
+import ExternalUserGroup from '~/features/external-user-group/server/models/external-user-group';
+import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
+import { SupportedAction } from '~/interfaces/activity';
+import Crowi from '~/server/crowi';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import { serializeUserGroupRelationSecurely } from '~/server/models/serializers/user-group-relation-serializer';
+import { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+import { configManager } from '~/server/service/config-manager';
+import UserGroupService from '~/server/service/user-group';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:routes:apiv3:external-user-group');
+
+const router = Router();
+
+interface AuthorizedRequest extends Request {
+  user?: any
+}
+
+module.exports = (crowi: Crowi): Router => {
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+  const adminRequired = require('~/server/middlewares/admin-required')(crowi);
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  const activityEvent = crowi.event('activity');
+
+  const isExecutingSync = () => {
+    return crowi.ldapUserGroupSyncService?.syncStatus?.isExecutingSync || crowi.keycloakUserGroupSyncService?.syncStatus?.isExecutingSync || false;
+  };
+
+  const validators = {
+    ldapSyncSettings: [
+      body('ldapGroupSearchBase').optional({ nullable: true }).isString(),
+      body('ldapGroupMembershipAttribute').exists({ checkFalsy: true }).isString(),
+      body('ldapGroupMembershipAttributeType').exists({ checkFalsy: true }).isString(),
+      body('ldapGroupChildGroupAttribute').exists({ checkFalsy: true }).isString(),
+      body('autoGenerateUserOnLdapGroupSync').exists().isBoolean(),
+      body('preserveDeletedLdapGroups').exists().isBoolean(),
+      body('ldapGroupNameAttribute').optional({ nullable: true }).isString(),
+      body('ldapGroupDescriptionAttribute').optional({ nullable: true }).isString(),
+    ],
+    keycloakSyncSettings: [
+      body('keycloakHost').exists({ checkFalsy: true }).isString(),
+      body('keycloakGroupRealm').exists({ checkFalsy: true }).isString(),
+      body('keycloakGroupSyncClientRealm').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(),
+    ],
+    ancestorGroup: [
+      query('groupId').isString(),
+    ],
+    update: [
+      body('description').optional().isString(),
+    ],
+    delete: [
+      param('id').trim().exists({ checkFalsy: true }),
+      query('actionName').trim().exists({ checkFalsy: true }),
+      query('transferToUserGroupId').trim(),
+    ],
+    detail: [
+      param('id').isString(),
+    ],
+  };
+
+  router.get('/', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    const { query } = req;
+
+    try {
+      const page = query.page != null ? parseInt(query.page as string) : undefined;
+      const limit = query.limit != null ? parseInt(query.limit as string) : undefined;
+      const offset = query.offset != null ? parseInt(query.offset as string) : undefined;
+      const pagination = query.pagination != null ? query.pagination !== 'false' : undefined;
+
+      const result = await ExternalUserGroup.findWithPagination({
+        page, limit, offset, pagination,
+      });
+      const { docs: userGroups, totalDocs: totalUserGroups, limit: pagingLimit } = result;
+      return res.apiv3({ userGroups, totalUserGroups, pagingLimit });
+    }
+    catch (err) {
+      const msg = 'Error occurred in fetching external user group list';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg));
+    }
+  });
+
+  router.get('/ancestors', loginRequiredStrictly, adminRequired, validators.ancestorGroup, apiV3FormValidator, async(req, res: ApiV3Response) => {
+    const { groupId } = req.query;
+
+    try {
+      const userGroup = await ExternalUserGroup.findOne({ _id: { $eq: groupId } });
+      const ancestorUserGroups = await ExternalUserGroup.findGroupsWithAncestorsRecursively(userGroup);
+      return res.apiv3({ ancestorUserGroups });
+    }
+    catch (err) {
+      const msg = 'Error occurred while searching user groups';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg));
+    }
+  });
+
+  router.get('/children', loginRequiredStrictly, adminRequired, validators.listChildren, async(req, res) => {
+    try {
+      const { parentIds, includeGrandChildren = false } = req.query;
+
+      const externalUserGroupsResult = await ExternalUserGroup.findChildrenByParentIds(parentIds, includeGrandChildren);
+      return res.apiv3({
+        childUserGroups: externalUserGroupsResult.childUserGroups,
+        grandChildUserGroups: externalUserGroupsResult.grandChildUserGroups,
+      });
+    }
+    catch (err) {
+      const msg = 'Error occurred in fetching child user group list';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg));
+    }
+  });
+
+  router.get('/:id', loginRequiredStrictly, adminRequired, validators.detail, async(req, res: ApiV3Response) => {
+    const { id } = req.params;
+
+    try {
+      const userGroup = await ExternalUserGroup.findById(id);
+      return res.apiv3({ userGroup });
+    }
+    catch (err) {
+      const msg = 'Error occurred while getting external user group';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg));
+    }
+  });
+
+  router.delete('/:id', loginRequiredStrictly, adminRequired, validators.delete, apiV3FormValidator, addActivity,
+    async(req: AuthorizedRequest, res: ApiV3Response) => {
+      const { id: deleteGroupId } = req.params;
+      const { actionName, transferToUserGroupId } = req.query;
+
+      const transferGroupInfo = transferToUserGroupId != null ? {
+        item: transferToUserGroupId as string,
+        type: GroupType.externalUserGroup,
+      } : undefined;
+
+      try {
+        const userGroups = await (crowi.userGroupService as UserGroupService)
+          .removeCompletelyByRootGroupId(deleteGroupId, actionName, req.user, transferGroupInfo, ExternalUserGroup, ExternalUserGroupRelation);
+
+        const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_DELETE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3({ userGroups });
+      }
+      catch (err) {
+        const msg = 'Error occurred while deleting user groups';
+        logger.error(msg, err);
+        return res.apiv3Err(new ErrorV3(msg));
+      }
+    });
+
+  router.put('/:id', loginRequiredStrictly, adminRequired, validators.update, apiV3FormValidator, addActivity, async(req, res: ApiV3Response) => {
+    const { id } = req.params;
+    const {
+      description,
+    } = req.body;
+
+    try {
+      const userGroup = await ExternalUserGroup.findOneAndUpdate({ _id: id }, { $set: { description } });
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
+      return res.apiv3({ userGroup });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating an external user group';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg));
+    }
+  });
+
+  router.get('/:id/external-user-group-relations', loginRequiredStrictly, adminRequired, async(req, res: ApiV3Response) => {
+    const { id } = req.params;
+
+    try {
+      const externalUserGroup = await ExternalUserGroup.findById(id);
+      const userGroupRelations = await ExternalUserGroupRelation.find({ relatedGroup: externalUserGroup })
+        .populate('relatedUser');
+      const serialized = userGroupRelations.map(relation => serializeUserGroupRelationSecurely(relation));
+      return res.apiv3({ userGroupRelations: serialized });
+    }
+    catch (err) {
+      const msg = `Error occurred in fetching user group relations for external user group: ${id}`;
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg));
+    }
+  });
+
+  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'),
+      ldapGroupMembershipAttributeType: configManager?.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttributeType'),
+      ldapGroupChildGroupAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupChildGroupAttribute'),
+      autoGenerateUserOnLdapGroupSync: configManager?.getConfig('crowi', 'external-user-group:ldap:autoGenerateUserOnGroupSync'),
+      preserveDeletedLdapGroups: configManager?.getConfig('crowi', 'external-user-group:ldap:preserveDeletedGroups'),
+      ldapGroupNameAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupNameAttribute'),
+      ldapGroupDescriptionAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupDescriptionAttribute'),
+    };
+
+    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'),
+      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'),
+      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()) {
+      return res.apiv3Err(
+        new ErrorV3('Invalid sync settings', 'external_user_group.invalid_sync_settings'), 400,
+      );
+    }
+
+    const params = {
+      'external-user-group:ldap:groupSearchBase': req.body.ldapGroupSearchBase,
+      'external-user-group:ldap:groupMembershipAttribute': req.body.ldapGroupMembershipAttribute,
+      'external-user-group:ldap:groupMembershipAttributeType': req.body.ldapGroupMembershipAttributeType,
+      'external-user-group:ldap:groupChildGroupAttribute': req.body.ldapGroupChildGroupAttribute,
+      'external-user-group:ldap:autoGenerateUserOnGroupSync': req.body.autoGenerateUserOnLdapGroupSync,
+      'external-user-group:ldap:preserveDeletedGroups': req.body.preserveDeletedLdapGroups,
+      'external-user-group:ldap:groupNameAttribute': req.body.ldapGroupNameAttribute,
+      'external-user-group:ldap:groupDescriptionAttribute': req.body.ldapGroupDescriptionAttribute,
+    };
+
+    if (params['external-user-group:ldap:groupNameAttribute'] == null || params['external-user-group:ldap:groupNameAttribute'] === '') {
+      // default is cn
+      params['external-user-group:ldap:groupNameAttribute'] = 'cn';
+    }
+
+    try {
+      await configManager.updateConfigsInTheSameNamespace('crowi', params, true);
+      return res.apiv3({}, 204);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(
+        new ErrorV3('Sync settings update failed', 'external_user_group.update_sync_settings_failed'), 500,
+      );
+    }
+  });
+
+  router.put('/keycloak/sync-settings', loginRequiredStrictly, adminRequired, validators.keycloakSyncSettings,
+    async(req: AuthorizedRequest, res: ApiV3Response) => {
+      const errors = validationResult(req);
+      if (!errors.isEmpty()) {
+        return res.apiv3Err(
+          new ErrorV3('Invalid sync settings', 'external_user_group.invalid_sync_settings'), 400,
+        );
+      }
+
+      const params = {
+        'external-user-group:keycloak:host': req.body.keycloakHost,
+        '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: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(
+          new ErrorV3('Sync settings update failed', 'external_user_group.update_sync_settings_failed'), 500,
+        );
+      }
+    });
+
+  router.put('/ldap/sync', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    if (isExecutingSync()) {
+      return res.apiv3Err(
+        new ErrorV3('There is an ongoing sync process', 'external_user_group.sync_being_executed'), 409,
+      );
+    }
+
+    const isLdapEnabled = await configManager.getConfig('crowi', 'security:passport-ldap:isEnabled');
+    if (!isLdapEnabled) {
+      return res.apiv3Err(
+        new ErrorV3('Authentication using ldap is not set', 'external_user_group.ldap.auth_not_set'), 422,
+      );
+    }
+
+    try {
+      await crowi.ldapUserGroupSyncService?.init(req.user.name, req.body.password);
+    }
+    catch (e) {
+      return res.apiv3Err(
+        new ErrorV3('LDAP group sync failed', 'external_user_group.sync_failed'), 500,
+      );
+    }
+
+    // Do not await for sync to finish. Result (completed, failed) will be notified to the client by socket-io.
+    crowi.ldapUserGroupSyncService?.syncExternalUserGroups();
+
+    return res.apiv3({}, 202);
+  });
+
+  router.put('/keycloak/sync', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    if (isExecutingSync()) {
+      return res.apiv3Err(
+        new ErrorV3('There is an ongoing sync process', 'external_user_group.sync_being_executed'), 409,
+      );
+    }
+
+    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'), 422,
+      );
+    }
+
+    crowi.keycloakUserGroupSyncService?.init(authProviderType);
+    // Do not await for sync to finish. Result (completed, failed) will be notified to the client by socket-io.
+    crowi.keycloakUserGroupSyncService?.syncExternalUserGroups();
+
+    return res.apiv3({}, 202);
+  });
+
+  router.get('/ldap/sync-status', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
+    const syncStatus = crowi.ldapUserGroupSyncService?.syncStatus;
+    return res.apiv3({ ...syncStatus });
+  });
+
+  router.get('/keycloak/sync-status', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
+    const syncStatus = crowi.keycloakUserGroupSyncService?.syncStatus;
+    return res.apiv3({ ...syncStatus });
+  });
+
+  return router;
+
+};

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

@@ -0,0 +1,224 @@
+import type { IUserHasId } from '@growi/core';
+
+import { SocketEventName } from '~/interfaces/websocket';
+import ExternalAccount from '~/server/models/external-account';
+import S2sMessage from '~/server/models/vo/s2s-message';
+import { S2sMessagingService } from '~/server/service/s2s-messaging/base';
+import { S2sMessageHandlable } from '~/server/service/s2s-messaging/handlable';
+import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
+import loggerFactory from '~/utils/logger';
+import { batchProcessPromiseAll } from '~/utils/promise';
+
+import { configManager } from '../../../../server/service/config-manager';
+import { externalAccountService } from '../../../../server/service/external-account';
+import {
+  ExternalGroupProviderType, ExternalUserGroupTreeNode, ExternalUserInfo, IExternalUserGroupHasId,
+} from '../../interfaces/external-user-group';
+import ExternalUserGroup from '../models/external-user-group';
+import ExternalUserGroupRelation from '../models/external-user-group-relation';
+
+const logger = loggerFactory('growi:service:external-user-group-sync-service');
+
+// When d = max depth of group trees
+// Max space complexity of syncExternalUserGroups will be:
+// O(TREES_BATCH_SIZE * d * USERS_BATCH_SIZE)
+const TREES_BATCH_SIZE = 10;
+const USERS_BATCH_SIZE = 30;
+
+type SyncStatus = { isExecutingSync: boolean, totalCount: number, count: number }
+
+class ExternalUserGroupSyncS2sMessage extends S2sMessage {
+
+  syncStatus: SyncStatus;
+
+}
+
+abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
+
+  groupProviderType: ExternalGroupProviderType; // name of external service that contains user group info (e.g: ldap, keycloak)
+
+  authProviderType: string | null; // auth provider type (e.g: ldap, oidc). Has to be set before syncExternalUserGroups execution.
+
+  socketIoService: any;
+
+  s2sMessagingService: S2sMessagingService | null;
+
+  syncStatus: SyncStatus = { isExecutingSync: false, totalCount: 0, count: 0 };
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  constructor(groupProviderType: ExternalGroupProviderType, s2sMessagingService: S2sMessagingService | null, socketIoService) {
+    this.groupProviderType = groupProviderType;
+    this.s2sMessagingService = s2sMessagingService;
+    this.socketIoService = socketIoService;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  shouldHandleS2sMessage(s2sMessage: ExternalUserGroupSyncS2sMessage): boolean {
+    return s2sMessage.eventName === 'switchExternalUserGroupExecSyncStatus';
+  }
+
+  /**
+   * @inheritdoc
+   */
+  async handleS2sMessage(s2sMessage: ExternalUserGroupSyncS2sMessage): Promise<void> {
+    logger.info('Update syncStatus by pubsub notification');
+    this.syncStatus = s2sMessage.syncStatus;
+  }
+
+  async setSyncStatus(syncStatus: SyncStatus): Promise<void> {
+    this.syncStatus = syncStatus;
+
+    if (this.s2sMessagingService != null) {
+      const s2sMessage = new ExternalUserGroupSyncS2sMessage('switchExternalUserGroupExecSyncStatus', {
+        syncStatus: this.syncStatus,
+      });
+
+      try {
+        await this.s2sMessagingService.publish(s2sMessage);
+      }
+      catch (e) {
+        logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
+      }
+    }
+  }
+
+  /** External user group tree sync method
+   * 1. Generate external user group tree
+   * 2. Use createUpdateExternalUserGroup on each node in the tree using DFS
+   * 3. If preserveDeletedLDAPGroups is false、delete all ExternalUserGroups that were not found during tree search
+  */
+  async syncExternalUserGroups(): Promise<void> {
+    if (this.authProviderType == null) throw new Error('auth provider type is not set');
+    if (this.syncStatus.isExecutingSync) throw new Error('External user group sync is already being executed');
+
+    const preserveDeletedLdapGroups: boolean = configManager?.getConfig('crowi', `external-user-group:${this.groupProviderType}:preserveDeletedGroups`);
+    const existingExternalUserGroupIds: string[] = [];
+
+    const socket = this.socketIoService?.getAdminSocket();
+
+    const syncNode = async(node: ExternalUserGroupTreeNode, parentId?: string) => {
+      const externalUserGroup = await this.createUpdateExternalUserGroup(node, parentId);
+      existingExternalUserGroupIds.push(externalUserGroup._id);
+      await this.setSyncStatus({ isExecutingSync: true, totalCount: this.syncStatus.totalCount, count:  this.syncStatus.count + 1 });
+      socket?.emit(SocketEventName.externalUserGroup[this.groupProviderType].GroupSyncProgress, {
+        totalCount: this.syncStatus.totalCount, count: this.syncStatus.count,
+      });
+      // Do not use Promise.all, because the number of promises processed can
+      // exponentially grow when group tree is enormous
+      for await (const childNode of node.childGroupNodes) {
+        await syncNode(childNode, externalUserGroup._id);
+      }
+    };
+
+    try {
+      const trees = await this.generateExternalUserGroupTrees();
+      const totalCount = trees.map(tree => this.getGroupCountOfTree(tree))
+        .reduce((sum, current) => sum + current);
+
+      await this.setSyncStatus({ isExecutingSync: true, totalCount, count: 0 });
+
+      await batchProcessPromiseAll(trees, TREES_BATCH_SIZE, async(tree) => {
+        return syncNode(tree);
+      });
+
+      if (!preserveDeletedLdapGroups) {
+        await ExternalUserGroup.deleteMany({
+          _id: { $nin: existingExternalUserGroupIds },
+          groupProviderType: this.groupProviderType,
+          provider: this.groupProviderType,
+        });
+        await ExternalUserGroupRelation.removeAllInvalidRelations();
+      }
+      socket?.emit(SocketEventName.externalUserGroup[this.groupProviderType].GroupSyncCompleted);
+    }
+    catch (e) {
+      logger.error(e.message);
+      socket?.emit(SocketEventName.externalUserGroup[this.groupProviderType].GroupSyncFailed);
+    }
+    finally {
+      await this.setSyncStatus({ isExecutingSync: false, totalCount: 0, count: 0 });
+    }
+  }
+
+  /** External user group node sync method
+   * 1. Create/Update ExternalUserGroup from using information of ExternalUserGroupTreeNode
+   * 2. For every element in node.userInfos, call getMemberUser and create an ExternalUserGroupRelation with ExternalUserGroup if it does not have one
+   * 3. Retrun ExternalUserGroup
+   * @param {string} node Node of external group tree
+   * @param {string} parentId Parent group id (id in GROWI) of the group we want to create/update
+   * @returns {Promise<IExternalUserGroupHasId>} ExternalUserGroup that was created/updated
+  */
+  private async createUpdateExternalUserGroup(node: ExternalUserGroupTreeNode, parentId?: string): Promise<IExternalUserGroupHasId> {
+    const externalUserGroup = await ExternalUserGroup.findAndUpdateOrCreateGroup(
+      node.name, node.id, this.groupProviderType, node.description, parentId,
+    );
+    await batchProcessPromiseAll(node.userInfos, USERS_BATCH_SIZE, async(userInfo) => {
+      const user = await this.getMemberUser(userInfo);
+
+      if (user != null) {
+        const userGroups = await ExternalUserGroup.findGroupsWithAncestorsRecursively(externalUserGroup);
+        const userGroupIds = userGroups.map(g => g._id);
+
+        // remove existing relations from list to create
+        const existingRelations = await ExternalUserGroupRelation.find({ relatedGroup: { $in: userGroupIds }, relatedUser: user._id });
+        const existingGroupIds = existingRelations.map(r => r.relatedGroup.toString());
+        const groupIdsToCreateRelation = excludeTestIdsFromTargetIds(userGroupIds, existingGroupIds);
+
+        await ExternalUserGroupRelation.createRelations(groupIdsToCreateRelation, user);
+      }
+    });
+
+    return externalUserGroup;
+  }
+
+  /** Method to get group member GROWI user
+   * 1. Search for GROWI user based on user info of 1, and return user
+   * 2. If autoGenerateUserOnHogeGroupSync is true and GROWI user is not found, create new GROWI user
+   * @param {ExternalUserInfo} externalUserInfo Search external app/server using this identifier
+   * @returns {Promise<IUserHasId | null>} User when found or created, null when neither
+   */
+  private async getMemberUser(userInfo: ExternalUserInfo): Promise<IUserHasId | null> {
+    const authProviderType = this.authProviderType;
+    if (authProviderType == null) throw new Error('auth provider type is not set');
+
+    const autoGenerateUserOnGroupSync = configManager?.getConfig('crowi', `external-user-group:${this.groupProviderType}:autoGenerateUserOnGroupSync`);
+
+    const getExternalAccount = async() => {
+      if (autoGenerateUserOnGroupSync && externalAccountService != null) {
+        return externalAccountService.getOrCreateUser({
+          id: userInfo.id, username: userInfo.username, name: userInfo.name, email: userInfo.email,
+        }, authProviderType);
+      }
+      return ExternalAccount.findOne({ providerType: this.groupProviderType, accountId: userInfo.id });
+    };
+
+    const externalAccount = await getExternalAccount();
+
+    if (externalAccount != null) {
+      return (await externalAccount.populate<{user: IUserHasId | null}>('user')).user;
+    }
+    return null;
+  }
+
+  getGroupCountOfTree(tree: ExternalUserGroupTreeNode): number {
+    if (tree.childGroupNodes.length === 0) return 1;
+
+    let count = 1;
+    tree.childGroupNodes.forEach((childGroup) => {
+      count += this.getGroupCountOfTree(childGroup);
+    });
+    return count;
+  }
+
+  /** Method to generate external group tree structure
+   * 1. Fetch user group info from external app/server
+   * 2. Convert each group tree structure to ExternalUserGroupTreeNode
+   * 3. Return the root node of each tree
+  */
+  abstract generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]>
+
+}
+
+export default ExternalUserGroupSyncService;

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

@@ -0,0 +1,210 @@
+import { configManager } from '~/server/service/config-manager';
+
+import { KeycloakUserGroupSyncService } from './keycloak-user-group-sync';
+
+vi.mock('@keycloak/keycloak-admin-client', () => {
+  return {
+    default: class {
+
+      auth() {}
+
+      groups = {
+        // mock group search on Keycloak
+        find: () => {
+          return [
+            // root node
+            {
+              id: 'groupId1',
+              name: 'grandParentGroup',
+              subGroups: [
+                {
+                  id: 'groupId2',
+                  name: 'parentGroup',
+                  subGroups: [
+                    {
+                      id: 'groupId3',
+                      name: 'childGroup',
+                    },
+                  ],
+                },
+              ],
+            },
+            // another root node
+            {
+              id: 'groupId4',
+              name: 'rootGroup',
+            },
+          ];
+        },
+
+        // mock group detail
+        findOne: (payload) => {
+          if (payload?.id === 'groupId1') {
+            return Promise.resolve(
+              {
+                id: 'groupId1',
+                name: 'grandParentGroup',
+                attributes: {
+                  description: ['this is a grand parent group'],
+                },
+              },
+            );
+          }
+          if (payload?.id === 'groupId2') {
+            return Promise.resolve(
+              {
+                id: 'groupId2',
+                name: 'parentGroup',
+                attributes: {
+                  description: ['this is a parent group'],
+                },
+              },
+            );
+          }
+          if (payload?.id === 'groupId3') {
+            return Promise.resolve(
+              {
+                id: 'groupId3',
+                name: 'childGroup',
+                attributes: {
+                  description: ['this is a child group'],
+                },
+              },
+            );
+          }
+          if (payload?.id === 'groupId4') {
+            return Promise.resolve(
+              {
+                id: 'groupId3',
+                name: 'childGroup',
+                attributes: {
+                  description: ['this is a root group'],
+                },
+              },
+            );
+          }
+          return Promise.reject(new Error('not found'));
+        },
+
+        // mock group users
+        listMembers: (payload) => {
+          // set 'first' condition to 0 (the first member request to server) or else it will result in infinite loop
+          if (payload?.id === 'groupId1' && payload?.first === 0) {
+            return Promise.resolve([
+              {
+                id: 'userId1',
+                username: 'grandParentGroupUser',
+                email: 'user@grandParentGroup.com',
+              },
+            ]);
+          }
+          if (payload?.id === 'groupId2' && payload?.first === 0) {
+            return Promise.resolve([
+              {
+                id: 'userId2',
+                username: 'parentGroupUser',
+                email: 'user@parentGroup.com',
+              },
+            ]);
+          }
+          if (payload?.id === 'groupId3' && payload?.first === 0) {
+            return Promise.resolve([
+              {
+                id: 'userId3',
+                username: 'childGroupUser',
+                email: 'user@childGroup.com',
+              },
+            ]);
+          }
+          if (payload?.id === 'groupId4' && payload?.first === 0) {
+            return Promise.resolve([
+              {
+                id: 'userId4',
+                username: 'rootGroupUser',
+                email: 'user@rootGroup.com',
+              },
+            ]);
+          }
+          return Promise.resolve([]);
+        },
+      };
+
+    },
+  };
+});
+
+describe('KeycloakUserGroupSyncService.generateExternalUserGroupTrees', () => {
+  let keycloakUserGroupSyncService: KeycloakUserGroupSyncService;
+
+  const configParams = {
+    'external-user-group:keycloak:host': 'http://dummy-keycloak-host.com',
+    'external-user-group:keycloak:groupRealm': 'myrealm',
+    'external-user-group:keycloak:groupSyncClientRealm': 'myrealm',
+    'external-user-group:keycloak:groupDescriptionAttribute': 'description',
+    'external-user-group:keycloak:groupSyncClientID': 'admin-cli',
+    'external-user-group:keycloak:groupSyncClientSecret': '123456',
+  };
+
+  beforeAll(async() => {
+    await configManager.updateConfigsInTheSameNamespace('crowi', configParams, true);
+    keycloakUserGroupSyncService = new KeycloakUserGroupSyncService(null, null);
+  });
+
+  it('creates ExternalUserGroupTrees', async() => {
+    const rootNodes = await keycloakUserGroupSyncService?.generateExternalUserGroupTrees();
+
+    expect(rootNodes?.length).toBe(2);
+
+    // check grandParentGroup
+    const grandParentNode = rootNodes?.find(node => node.id === 'groupId1');
+    const expectedChildNode = {
+      id: 'groupId3',
+      userInfos: [{
+        id: 'userId3',
+        username: 'childGroupUser',
+        email: 'user@childGroup.com',
+      }],
+      childGroupNodes: [],
+      name: 'childGroup',
+      description: 'this is a child group',
+    };
+    const expectedParentNode = {
+      id: 'groupId2',
+      userInfos: [{
+        id: 'userId2',
+        username: 'parentGroupUser',
+        email: 'user@parentGroup.com',
+      }],
+      childGroupNodes: [expectedChildNode],
+      name: 'parentGroup',
+      description: 'this is a parent group',
+    };
+    const expectedGrandParentNode = {
+      id: 'groupId1',
+      userInfos: [{
+        id: 'userId1',
+        username: 'grandParentGroupUser',
+        email: 'user@grandParentGroup.com',
+      }],
+      childGroupNodes: [expectedParentNode],
+      name: 'grandParentGroup',
+      description: 'this is a grand parent group',
+    };
+    expect(grandParentNode).toStrictEqual(expectedGrandParentNode);
+
+    // check rootGroup
+    const rootNode = rootNodes?.find(node => node.id === 'groupId4');
+    const expectedRootNode = {
+      id: 'groupId4',
+      userInfos: [{
+        id: 'userId4',
+        username: 'rootGroupUser',
+        email: 'user@rootGroup.com',
+      }],
+      childGroupNodes: [],
+      name: 'rootGroup',
+      description: 'this is a root group',
+    };
+    expect(rootNode).toStrictEqual(expectedRootNode);
+  });
+});

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

@@ -0,0 +1,168 @@
+import KeycloakAdminClient from '@keycloak/keycloak-admin-client';
+import GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation';
+import UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation';
+
+import { configManager } from '~/server/service/config-manager';
+import { S2sMessagingService } from '~/server/service/s2s-messaging/base';
+import loggerFactory from '~/utils/logger';
+import { batchProcessPromiseAll } from '~/utils/promise';
+
+import { ExternalGroupProviderType, ExternalUserGroupTreeNode, ExternalUserInfo } from '../../interfaces/external-user-group';
+
+import ExternalUserGroupSyncService from './external-user-group-sync';
+
+const logger = loggerFactory('growi:service:keycloak-user-group-sync-service');
+
+// When d = max depth of group trees
+// Max space complexity of generateExternalUserGroupTrees will be:
+// O(TREES_BATCH_SIZE * d)
+const TREES_BATCH_SIZE = 10;
+
+export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
+
+  kcAdminClient: KeycloakAdminClient;
+
+  realm: string; // realm that contains the groups
+
+  groupDescriptionAttribute: string; // attribute to map to group description
+
+  isInitialized = false;
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  constructor(s2sMessagingService: S2sMessagingService | null, socketIoService) {
+    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, s2sMessagingService, socketIoService);
+    this.kcAdminClient = new KeycloakAdminClient({ baseUrl: kcHost, realmName: kcGroupSyncClientRealm });
+    this.realm = kcGroupRealm;
+    this.groupDescriptionAttribute = kcGroupDescriptionAttribute;
+  }
+
+  init(authProviderType: 'oidc' | 'saml'): void {
+    this.authProviderType = authProviderType;
+    this.isInitialized = true;
+  }
+
+  override syncExternalUserGroups(): Promise<void> {
+    if (!this.isInitialized) {
+      const msg = 'Service not initialized';
+      logger.error(msg);
+      throw new Error(msg);
+    }
+    return super.syncExternalUserGroups();
+  }
+
+  override async generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]> {
+    await this.auth();
+
+    // Type is 'GroupRepresentation', but 'find' does not return 'attributes' field. Hence, attribute for description is not present.
+    logger.info('Get groups from keycloak server');
+    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;
+
+    logger.info('Get users from keycloak server');
+    const userRepresentations = await this.getMembers(group.id);
+
+    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 getMembers(groupId: string): Promise<UserRepresentation[]> {
+    let allUsers: UserRepresentation[] = [];
+
+    const fetchUsersWithOffset = async(offset: number) => {
+      await this.auth();
+      const response = await this.kcAdminClient.groups.listMembers({
+        id: groupId, realm: this.realm, first: offset,
+      });
+
+      if (response != null && response.length > 0) {
+        allUsers = allUsers.concat(response);
+        return fetchUsersWithOffset(offset + response.length);
+      }
+    };
+
+    await fetchUsersWithOffset(0);
+
+    return allUsers;
+  }
+
+
+  /**
+   * Fetch group detail from Keycloak and return group description
+   */
+  private async getGroupDescription(groupId: string): Promise<string | null> {
+    if (this.groupDescriptionAttribute == null) return null;
+
+    await this.auth();
+    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);
+  }
+
+}

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

@@ -0,0 +1,156 @@
+import { configManager } from '~/server/service/config-manager';
+import { ldapService, SearchResultEntry } from '~/server/service/ldap';
+import PassportService from '~/server/service/passport';
+import { S2sMessagingService } from '~/server/service/s2s-messaging/base';
+import loggerFactory from '~/utils/logger';
+import { batchProcessPromiseAll } from '~/utils/promise';
+
+import {
+  ExternalGroupProviderType, ExternalUserGroupTreeNode, ExternalUserInfo, LdapGroupMembershipAttributeType,
+} from '../../interfaces/external-user-group';
+
+import ExternalUserGroupSyncService from './external-user-group-sync';
+
+const logger = loggerFactory('growi:service:ldap-user-group-sync-service');
+
+// When d = max depth of group trees
+// Max space complexity of generateExternalUserGroupTrees will be:
+// O(TREES_BATCH_SIZE * d * USERS_BATCH_SIZE)
+const TREES_BATCH_SIZE = 10;
+const USERS_BATCH_SIZE = 30;
+
+export class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
+
+  passportService: PassportService;
+
+  isInitialized = false;
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  constructor(passportService: PassportService, s2sMessagingService: S2sMessagingService, socketIoService) {
+    super(ExternalGroupProviderType.ldap, s2sMessagingService, socketIoService);
+    this.authProviderType = 'ldap';
+    this.passportService = passportService;
+  }
+
+  async init(userBindUsername?: string, userBindPassword?: string): Promise<void> {
+    await ldapService.initClient(userBindUsername, userBindPassword);
+    this.isInitialized = true;
+  }
+
+  override syncExternalUserGroups(): Promise<void> {
+    if (!this.isInitialized) {
+      const msg = 'Service not initialized';
+      logger.error(msg);
+      throw new Error(msg);
+    }
+    return super.syncExternalUserGroups();
+  }
+
+  override async generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]> {
+    const groupChildGroupAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupChildGroupAttribute');
+    const groupMembershipAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttribute');
+    const groupNameAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupNameAttribute');
+    const groupDescriptionAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupDescriptionAttribute');
+    const groupBase: string = ldapService.getGroupSearchBase();
+
+    const groupEntries = await ldapService.searchGroupDir();
+
+    const getChildGroupDnsFromGroupEntry = (groupEntry: SearchResultEntry) => {
+      // groupChildGroupAttribute and groupMembershipAttribute may be the same,
+      // so filter values of groupChildGroupAttribute to ones that include groupBase
+      return ldapService.getArrayValFromSearchResultEntry(groupEntry, groupChildGroupAttribute).filter(attr => attr.includes(groupBase));
+    };
+    const getUserIdsFromGroupEntry = (groupEntry: SearchResultEntry) => {
+      // groupChildGroupAttribute and groupMembershipAttribute may be the same,
+      // so filter values of groupMembershipAttribute to ones that does not include groupBase
+      return ldapService.getArrayValFromSearchResultEntry(groupEntry, groupMembershipAttribute).filter(attr => !attr.includes(groupBase));
+    };
+
+    const convert = async(entry: SearchResultEntry, converted: string[]): Promise<ExternalUserGroupTreeNode | null> => {
+      const name = ldapService.getStringValFromSearchResultEntry(entry, groupNameAttribute);
+      if (name == null) return null;
+
+      if (converted.includes(entry.objectName)) {
+        throw Error('Circular reference inside LDAP group tree');
+      }
+      converted.push(entry.objectName);
+
+      const userIds = getUserIdsFromGroupEntry(entry);
+
+      const userInfos = (await batchProcessPromiseAll(userIds, USERS_BATCH_SIZE, (id) => {
+        return this.getUserInfo(id);
+      })).filter((info): info is NonNullable<ExternalUserInfo> => info != null);
+      const description = ldapService.getStringValFromSearchResultEntry(entry, groupDescriptionAttribute);
+      const childGroupDNs = getChildGroupDnsFromGroupEntry(entry);
+
+      const childGroupNodesWithNull: (ExternalUserGroupTreeNode | null)[] = [];
+      // Do not use Promise.all, because the number of promises processed can
+      // exponentially grow when group tree is enormous
+      for await (const dn of childGroupDNs) {
+        const childEntry = groupEntries.find(ge => ge.objectName === dn);
+        childGroupNodesWithNull.push(childEntry != null ? await convert(childEntry, converted) : null);
+      }
+      const childGroupNodes: ExternalUserGroupTreeNode[] = childGroupNodesWithNull
+        .filter((node): node is NonNullable<ExternalUserGroupTreeNode> => node != null);
+
+      return {
+        id: entry.objectName,
+        userInfos,
+        childGroupNodes,
+        name,
+        description,
+      };
+    };
+
+    // all the DNs of groups that are not a root of a tree
+    const allChildGroupDNs = new Set(groupEntries.flatMap((entry) => {
+      return getChildGroupDnsFromGroupEntry(entry);
+    }));
+
+    // root of every tree
+    const rootEntries = groupEntries.filter((entry) => {
+      return !allChildGroupDNs.has(entry.objectName);
+    });
+
+    return (await batchProcessPromiseAll(rootEntries, TREES_BATCH_SIZE, entry => convert(entry, [])))
+      .filter((node): node is NonNullable<ExternalUserGroupTreeNode> => node != null);
+  }
+
+  private async getUserInfo(userId: string): Promise<ExternalUserInfo | null> {
+    const groupMembershipAttributeType = configManager?.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttributeType');
+    const attrMapUsername = this.passportService.getLdapAttrNameMappedToUsername();
+    const attrMapName = this.passportService.getLdapAttrNameMappedToName();
+    const attrMapMail = this.passportService.getLdapAttrNameMappedToMail();
+
+    // get full user info from LDAP server using externalUserInfo (DN or UID)
+    const getUserEntries = async() => {
+      if (groupMembershipAttributeType === LdapGroupMembershipAttributeType.dn) {
+        return ldapService.search(undefined, userId, 'base');
+      }
+      if (groupMembershipAttributeType === LdapGroupMembershipAttributeType.uid) {
+        return ldapService.search(`(uid=${userId})`, undefined);
+      }
+    };
+
+    const userEntries = await getUserEntries();
+
+    if (userEntries != null && userEntries.length > 0) {
+      const userEntry = userEntries[0];
+      const uid = ldapService.getStringValFromSearchResultEntry(userEntry, 'uid');
+      if (uid != null) {
+        const usernameToBeRegistered = attrMapUsername === 'uid' ? uid : ldapService.getStringValFromSearchResultEntry(userEntry, attrMapUsername);
+        const nameToBeRegistered = ldapService.getStringValFromSearchResultEntry(userEntry, attrMapName);
+        const mailToBeRegistered = ldapService.getStringValFromSearchResultEntry(userEntry, attrMapMail);
+
+        return usernameToBeRegistered != null ? {
+          id: uid,
+          username: usernameToBeRegistered,
+          name: nameToBeRegistered,
+          email: mailToBeRegistered,
+        } : null;
+      }
+    }
+    return null;
+  }
+
+}

+ 9 - 2
apps/app/src/interfaces/page-grant.ts

@@ -1,9 +1,16 @@
-import { PageGrant } from '@growi/core';
+import { PageGrant, GroupType } from '@growi/core';
+
+import { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
+import { UserGroupDocument } from '~/server/models/user-group';
 
 import { IPageGrantData } from './page';
 
+
+type UserGroupType = typeof GroupType.userGroup;
+type ExternalUserGroupType = typeof GroupType.externalUserGroup;
+export type ApplicableGroup = {type: UserGroupType, item: UserGroupDocument } | {type: ExternalUserGroupType, item: ExternalUserGroupDocument }
 export type IDataApplicableGroup = {
-  applicableGroups?: {_id: string, name: string}[] // TODO: Typescriptize model
+  applicableGroups?: ApplicableGroup[]
 }
 
 export type IDataApplicableGrant = null | IDataApplicableGroup;

+ 3 - 2
apps/app/src/interfaces/page-operation.ts

@@ -1,3 +1,5 @@
+import type { IGrantedGroup } from '@growi/core';
+
 export const PageActionType = {
   Create: 'Create',
   Update: 'Update',
@@ -32,7 +34,6 @@ export type OptionsToSave = {
   slackChannels: string;
   grant: number;
   pageTags: string[] | null;
-  grantUserGroupId?: string | null;
-  grantUserGroupName?: string | null;
+  grantUserGroupIds?: IGrantedGroup[];
   isSyncRevisionToHackmd?: boolean;
 };

+ 5 - 4
apps/app/src/interfaces/page.ts

@@ -1,4 +1,4 @@
-import type { IPageHasId, Nullable } from '@growi/core';
+import type { GroupType, IPageHasId, Nullable } from '@growi/core';
 
 import type { IPageOperationProcessData } from './page-operation';
 
@@ -10,10 +10,11 @@ export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean, processData
 
 export type IPageGrantData = {
   grant: number,
-  grantedGroup?: {
+  grantedGroups?: {
     id: string,
-    name: string
-  }
+    name: string,
+    type: GroupType,
+  }[]
 }
 
 export type IDeleteSinglePageApiv1Result = {

+ 12 - 11
apps/app/src/interfaces/user-group-response.ts

@@ -1,29 +1,30 @@
 import type {
   HasObjectId, Ref,
   IPageHasId,
-  IUser, IUserGroup, IUserGroupHasId, IUserGroupRelationHasId,
+  IUserGroup, IUserGroupHasId, IUserGroupRelationHasId, IUserHasId,
 } from '@growi/core';
 
+
 export type UserGroupResult = {
   userGroup: IUserGroupHasId,
 }
 
-export type UserGroupListResult = {
-  userGroups: IUserGroupHasId[],
+export type UserGroupListResult<TUSERGROUP extends IUserGroupHasId = IUserGroupHasId> = {
+  userGroups: TUSERGROUP[],
 };
 
-export type ChildUserGroupListResult = {
-  childUserGroups: IUserGroupHasId[],
-  grandChildUserGroups: IUserGroupHasId[],
+export type ChildUserGroupListResult<TUSERGROUP extends IUserGroupHasId = IUserGroupHasId> = {
+  childUserGroups: TUSERGROUP[],
+  grandChildUserGroups: TUSERGROUP[],
 };
 
-export type UserGroupRelationListResult = {
-  userGroupRelations: IUserGroupRelationHasId[],
+export type UserGroupRelationListResult<TUSERGROUPRELATION extends IUserGroupRelationHasId = IUserGroupRelationHasId> = {
+  userGroupRelations: TUSERGROUPRELATION[],
 };
 
-export type IUserGroupRelationHasIdPopulatedUser = {
-  relatedGroup: Ref<IUserGroup>,
-  relatedUser: IUser & HasObjectId,
+export type IUserGroupRelationHasIdPopulatedUser<TUSERGROUP extends IUserGroup = IUserGroup> = {
+  relatedGroup: Ref<TUSERGROUP>,
+  relatedUser: IUserHasId,
   createdAt: Date,
 } & HasObjectId;
 

+ 23 - 0
apps/app/src/interfaces/websocket.ts

@@ -1,3 +1,23 @@
+import { ExternalGroupProviderType } from '~/features/external-user-group/interfaces/external-user-group';
+
+const generateGroupSyncEvents = () => {
+  const events = {};
+  Object.values(ExternalGroupProviderType).forEach((provider) => {
+    events[provider] = {
+      GroupSyncProgress: `${provider}:groupSyncProgress`,
+      GroupSyncCompleted: `${provider}:groupSyncCompleted`,
+      GroupSyncFailed: `${provider}:groupSyncFailed`,
+    };
+  });
+  return events as {
+    [key in ExternalGroupProviderType]: {
+      GroupSyncProgress: string,
+      GroupSyncCompleted: string,
+      GroupSyncFailed: string,
+    }
+  };
+};
+
 export const SocketEventName = {
   // Update descendantCount
   UpdateDescCount: 'UpdateDescCount',
@@ -17,6 +37,9 @@ export const SocketEventName = {
   FinishAddPage: 'finishAddPage',
   RebuildingFailed: 'rebuildingFailed',
 
+  // External user group sync
+  externalUserGroup: generateGroupSyncEvents(),
+
   // Page Operation
   PageCreated: 'page:create',
   PageUpdated: 'page:update',

+ 1 - 1
apps/app/src/migrations/20200402160380-remove-deleteduser-from-relationgroup.js

@@ -1,5 +1,6 @@
 import mongoose from 'mongoose';
 
+import UserGroupRelation from '~/server/models/user-group-relation';
 import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
@@ -11,7 +12,6 @@ module.exports = {
     mongoose.connect(getMongoUri(), mongoOptions);
 
     const User = getModelSafely('User') || require('~/server/models/user')();
-    const UserGroupRelation = getModelSafely('UserGroupRelation') || require('~/server/models/user-group-relation')();
 
     const deletedUsers = await User.find({ status: 4 }); // deleted user
     const requests = await UserGroupRelation.remove({ relatedUser: deletedUsers });

+ 160 - 0
apps/app/src/migrations/20230723061824-granted-group-to-array-of-objects.js

@@ -0,0 +1,160 @@
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:remove-basic-auth-related-config');
+
+module.exports = {
+  async up(db, client) {
+    logger.info('Apply migration');
+
+    const pageCollection = await db.collection('pages');
+    const pageOperationCollection = await db.collection('pageoperations');
+    // Convert grantedGroup to array
+    // Set the model type of grantedGroup to UserGroup for Pages that were created before ExternalUserGroup was introduced
+    await pageCollection.updateMany(
+      { grantedGroup: { $ne: null } },
+      [
+        {
+          $set: {
+            grantedGroup: [
+              {
+                type: 'UserGroup',
+                item: '$grantedGroup',
+              },
+            ],
+          },
+        },
+      ],
+    );
+
+    await pageOperationCollection.updateMany(
+      { 'options.grantUserGroupId': { $ne: null } },
+      [
+        {
+          $set: {
+            'options.grantUserGroupId': [
+              {
+                type: 'UserGroup',
+                item: '$options.grantUserGroupId',
+              },
+            ],
+          },
+        },
+      ],
+    );
+
+    await pageOperationCollection.updateMany(
+      { 'page.grantedGroup': { $ne: null } },
+      [
+        {
+          $set: {
+            'page.grantedGroup': [
+              {
+                type: 'UserGroup',
+                item: '$page.grantedGroup',
+              },
+            ],
+          },
+        },
+      ],
+    );
+
+    await pageOperationCollection.updateMany(
+      { 'exPage.grantedGroup': { $ne: null } },
+      [
+        {
+          $set: {
+            'exPage.grantedGroup': [
+              {
+                type: 'UserGroup',
+                item: '$exPage.grantedGroup',
+              },
+            ],
+          },
+        },
+      ],
+    );
+
+    // rename fields
+    await pageCollection.updateMany({}, {
+      $rename: {
+        grantedGroup: 'grantedGroups',
+      },
+    });
+    await pageOperationCollection.updateMany({}, {
+      $rename: {
+        'options.grantUserGroupId': 'options.grantUserGroupIds',
+        'page.grantedGroup': 'page.grantedGroups',
+        'exPage.grantedGroup': 'exPage.grantedGroups',
+      },
+    });
+
+    logger.info('Migration has successfully applied');
+  },
+
+  async down(db, client) {
+    logger.info('Rollback migration');
+
+    const pageCollection = await db.collection('pages');
+    const pageOperationCollection = await db.collection('pageoperations');
+
+    await pageCollection.updateMany(
+      { grantedGroups: { $exists: true } },
+      [
+        {
+          $set: {
+            grantedGroups: { $arrayElemAt: ['$grantedGroups.item', 0] },
+          },
+        },
+      ],
+    );
+    await pageOperationCollection.updateMany(
+      { 'options.grantUserGroupIds': { $exists: true } },
+      [
+        {
+          $set: {
+            'options.grantUserGroupIds': { $arrayElemAt: ['options.grantUserGroupIds.item', 0] },
+          },
+        },
+      ],
+    );
+    await pageOperationCollection.updateMany(
+      { 'page.grantedGroups': { $exists: true } },
+      [
+        {
+          $set: {
+            'page.grantedGroups': { $arrayElemAt: ['page.grantedGroups.item', 0] },
+          },
+        },
+      ],
+    );
+    await pageOperationCollection.updateMany(
+      { 'exPage.grantedGroups': { $exists: true } },
+      [
+        {
+          $set: {
+            'exPage.grantedGroups': { $arrayElemAt: ['exPage.grantedGroups.item', 0] },
+          },
+        },
+      ],
+    );
+
+    // rename fields
+    await pageCollection.updateMany(
+      { grantedGroups: { $exists: true } },
+      {
+        $rename: {
+          grantedGroups: 'grantedGroup',
+        },
+      },
+    );
+    await pageOperationCollection.updateMany({}, {
+      $rename: {
+        'options.grantUserGroupIds': 'options.grantUserGroupId',
+        'page.grantedGroups': 'page.grantedGroup',
+        'exPage.grantedGroups': 'exPage.grantedGroup',
+      },
+    });
+
+    logger.info('Migration has been successfully rollbacked');
+  },
+};

+ 11 - 8
apps/app/src/pages/[[...path]].page.tsx

@@ -419,17 +419,20 @@ class MultiplePagesHitsError extends ExtensibleCustomError {
 
 // apply parent page grant fot creating page
 async function applyGrantToPage(props: Props, ancestor: any) {
-  await ancestor.populate('grantedGroup');
+  await ancestor.populate('grantedGroups.item');
   const grant = {
     grant: ancestor.grant,
   };
-  const grantedGroup = ancestor.grantedGroup ? {
-    grantedGroup: {
-      id: ancestor.grantedGroup.id,
-      name: ancestor.grantedGroup.name,
-    },
+  const grantedGroups = ancestor.grantedGroups ? {
+    grantedGroups: ancestor.grantedGroups.map((group) => {
+      return {
+        id: group.item._id,
+        name: group.item.name,
+        type: group.type,
+      };
+    }),
   } : {};
-  props.grantData = Object.assign(grant, grantedGroup);
+  props.grantData = Object.assign(grant, grantedGroups);
 }
 
 async function injectPageData(context: GetServerSidePropsContext, props: Props): Promise<void> {
@@ -492,7 +495,7 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
       props.templateBodyData = templateData.templateBody as string;
     }
 
-    // apply pagrent page grant
+    // apply parent page grant
     const ancestor = await Page.findAncestorByPathAndViewer(currentPathname, user);
     if (ancestor != null) {
       await applyGrantToPage(props, ancestor);

+ 4 - 2
apps/app/src/pages/admin/user-group-detail/[userGroupId].page.tsx

@@ -26,13 +26,15 @@ const AdminUserGroupDetailPage: NextPage<Props> = (props: Props) => {
   useIsMaintenanceMode(props.isMaintenanceMode);
   useCurrentUser(props.currentUser ?? null);
   const router = useRouter();
-  const { userGroupId } = router.query;
+  const { userGroupId, isExternalGroup } = router.query;
 
   const title = t('user_group_management.user_group_management');
   const customTitle = generateCustomTitle(props, title);
 
   const currentUserGroupId = Array.isArray(userGroupId) ? userGroupId[0] : userGroupId;
 
+  const isExternalGroupBool = isExternalGroup === 'true';
+
   useIsAclEnabled(props.isAclEnabled);
 
   if (props.isAccessDeniedForNonAdminUser) {
@@ -46,7 +48,7 @@ const AdminUserGroupDetailPage: NextPage<Props> = (props: Props) => {
       </Head>
       {
         currentUserGroupId != null && router.isReady
-      && <UserGroupDetailPage userGroupId={currentUserGroupId} />
+      && <UserGroupDetailPage userGroupId={currentUserGroupId} isExternalGroup={isExternalGroupBool} />
       }
     </AdminLayout>
   );

+ 34 - 16
apps/app/src/server/crowi/index.js

@@ -10,6 +10,8 @@ import next from 'next';
 
 import pkg from '^/package.json';
 
+import { KeycloakUserGroupSyncService } from '~/features/external-user-group/server/service/keycloak-user-group-sync';
+import { LdapUserGroupSyncService } from '~/features/external-user-group/server/service/ldap-user-group-sync';
 import QuestionnaireService from '~/features/questionnaire/server/service/questionnaire';
 import QuestionnaireCronService from '~/features/questionnaire/server/service/questionnaire-cron';
 import CdnResourcesService from '~/services/cdn-resources-service';
@@ -23,17 +25,21 @@ import PageRedirect from '../models/page-redirect';
 import ShareLink from '../models/share-link';
 import Tag from '../models/tag';
 import UserGroup from '../models/user-group';
+import UserGroupRelation from '../models/user-group-relation';
 import { aclService as aclServiceSingletonInstance } from '../service/acl';
 import AppService from '../service/app';
 import AttachmentService from '../service/attachment';
 import { configManager as configManagerSingletonInstance } from '../service/config-manager';
+import { instanciate as instanciateExternalAccountService } from '../service/external-account';
 import { G2GTransferPusherService, G2GTransferReceiverService } from '../service/g2g-transfer';
 import { InstallerService } from '../service/installer';
 import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
 import PageOperationService from '../service/page-operation';
+import PassportService from '../service/passport';
 import SearchService from '../service/search';
 import { SlackIntegrationService } from '../service/slack-integration';
+import UserGroupService from '../service/user-group';
 import { UserNotificationService } from '../service/user-notification';
 import { getMongoUri, mongoOptions } from '../util/mongoose-utils';
 
@@ -152,10 +158,13 @@ Crowi.prototype.init = async function() {
     this.setUpCustomize(), // depends on pluginService
   ]);
 
-  // globalNotification depends on slack and mailer
   await Promise.all([
+    // globalNotification depends on slack and mailer
     this.setUpGlobalNotification(),
     this.setUpUserNotification(),
+    // depends on passport service
+    this.setupExternalAccountService(),
+    this.setupExternalUserGroupSyncService(),
   ]);
 
   await this.autoInstall();
@@ -294,22 +303,22 @@ Crowi.prototype.setupSocketIoService = async function() {
 };
 
 Crowi.prototype.setupModels = async function() {
-  let allModels = {};
-
-  // include models that dependent on crowi
-  allModels = models;
-
-  // include models that independent from crowi
-  allModels.Activity = Activity;
-  allModels.Tag = Tag;
-  allModels.UserGroup = UserGroup;
-  allModels.PageRedirect = PageRedirect;
-  allModels.ShareLink = ShareLink;
-
-  Object.keys(allModels).forEach((key) => {
+  Object.keys(models).forEach((key) => {
     return this.model(key, models[key](this));
   });
 
+  // include models that are independent from crowi
+  const crowiIndependent = {};
+  crowiIndependent.Activity = Activity;
+  crowiIndependent.Tag = Tag;
+  crowiIndependent.UserGroup = UserGroup;
+  crowiIndependent.UserGroupRelation = UserGroupRelation;
+  crowiIndependent.PageRedirect = PageRedirect;
+  crowiIndependent.ShareLink = ShareLink;
+
+  Object.keys(crowiIndependent).forEach((key) => {
+    return this.model(key, crowiIndependent[key]);
+  });
 };
 
 Crowi.prototype.setupCron = function() {
@@ -360,7 +369,6 @@ Crowi.prototype.setupPassport = async function() {
   logger.debug('Passport is enabled');
 
   // initialize service
-  const PassportService = require('../service/passport');
   if (this.passportService == null) {
     this.passportService = new PassportService(this);
   }
@@ -669,7 +677,6 @@ Crowi.prototype.setUpRestQiitaAPI = async function() {
 };
 
 Crowi.prototype.setupUserGroupService = async function() {
-  const UserGroupService = require('../service/user-group');
   if (this.userGroupService == null) {
     this.userGroupService = new UserGroupService(this);
     return this.userGroupService.init();
@@ -772,4 +779,15 @@ Crowi.prototype.setupG2GTransferService = async function() {
   }
 };
 
+// execute after setupPassport
+Crowi.prototype.setupExternalAccountService = function() {
+  instanciateExternalAccountService(this.passportService);
+};
+
+// execute after setupPassport, s2sMessagingService, socketIoService
+Crowi.prototype.setupExternalUserGroupSyncService = function() {
+  this.ldapUserGroupSyncService = new LdapUserGroupSyncService(this.passportService, this.s2sMessagingService, this.socketIoService);
+  this.keycloakUserGroupSyncService = new KeycloakUserGroupSyncService(this.s2sMessagingService, this.socketIoService);
+};
+
 export default Crowi;

+ 6 - 0
apps/app/src/server/models/config.ts

@@ -143,6 +143,12 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
   'importer:esa:access_token': undefined,
   'importer:qiita:team_name': undefined,
   'importer:qiita:access_token': undefined,
+
+  'external-user-group:ldap:groupMembershipAttributeType': 'DN',
+  'external-user-group:ldap:autoGenerateUserOnGroupSync': false,
+  'external-user-group:ldap:preserveDeletedGroups': false,
+  'external-user-group:keycloak:autoGenerateUserOnGroupSync': false,
+  'external-user-group:keycloak:preserveDeletedGroups': false,
   /* eslint-enable key-spacing */
 };
 

+ 0 - 187
apps/app/src/server/models/external-account.js

@@ -1,187 +0,0 @@
-// disable no-return-await for model functions
-/* eslint-disable no-return-await */
-import { NullUsernameToBeRegisteredError } from '~/server/models/errors';
-
-const debug = require('debug')('growi:models:external-account');
-const mongoose = require('mongoose');
-const mongoosePaginate = require('mongoose-paginate-v2');
-const uniqueValidator = require('mongoose-unique-validator');
-
-const ObjectId = mongoose.Schema.Types.ObjectId;
-
-/*
- * define schema
- */
-const schema = new mongoose.Schema({
-  providerType: { type: String, required: true },
-  accountId: { type: String, required: true },
-  user: { type: ObjectId, ref: 'User', required: true },
-}, {
-  timestamps: { createdAt: true, updatedAt: false },
-});
-// compound index
-schema.index({ providerType: 1, accountId: 1 }, { unique: true });
-// apply plugins
-schema.plugin(mongoosePaginate);
-schema.plugin(uniqueValidator);
-
-/**
- * The Exception class thrown when User.username is duplicated when creating user
- *
- * @class DuplicatedUsernameException
- */
-class DuplicatedUsernameException {
-
-  constructor(message, user) {
-    this.name = this.constructor.name;
-    this.message = message;
-    this.user = user;
-  }
-
-}
-
-/**
- * ExternalAccount Class
- *
- * @class ExternalAccount
- */
-class ExternalAccount {
-
-  /**
-   * limit items num for pagination
-   *
-   * @readonly
-   * @static
-   * @memberof ExternalAccount
-   */
-  static get DEFAULT_LIMIT() {
-    return 50;
-  }
-
-  static set crowi(crowi) {
-    this._crowi = crowi;
-  }
-
-  static get crowi() {
-    return this._crowi;
-  }
-
-  /**
-   * get the populated user entity
-   *
-   * @returns Promise<User>
-   * @memberof ExternalAccount
-   */
-  getPopulatedUser() {
-    return this.populate('user')
-      .then((account) => {
-        return account.user;
-      });
-  }
-
-  /**
-   * find an account or register if not found
-   *
-   * @static
-   * @param {string} providerType
-   * @param {string} accountId
-   * @param {object} usernameToBeRegistered the username of User entity that will be created when accountId is not found
-   * @param {object} nameToBeRegistered the name of User entity that will be created when accountId is not found
-   * @param {object} mailToBeRegistered the mail of User entity that will be created when accountId is not found
-   * @param {boolean} isSameUsernameTreatedAsIdenticalUser
-   * @param {boolean} isSameEmailTreatedAsIdenticalUser
-   * @returns {Promise<ExternalAccount>}
-   * @memberof ExternalAccount
-   */
-  static findOrRegister(providerType, accountId,
-      usernameToBeRegistered, nameToBeRegistered, mailToBeRegistered,
-      isSameUsernameTreatedAsIdenticalUser, isSameEmailTreatedAsIdenticalUser) {
-    //
-    return this.findOne({ providerType, accountId })
-      .then((account) => {
-        // ExternalAccount is found
-        if (account != null) {
-          debug(`ExternalAccount '${accountId}' is found `, account);
-          return account;
-        }
-
-        if (usernameToBeRegistered == null) {
-          throw new NullUsernameToBeRegisteredError('username_should_not_be_null');
-        }
-
-        const User = ExternalAccount.crowi.model('User');
-
-        let promise = User.findOne({ username: usernameToBeRegistered });
-        if (isSameUsernameTreatedAsIdenticalUser && isSameEmailTreatedAsIdenticalUser) {
-          promise = promise
-            .then((user) => {
-              if (user == null) { return User.findOne({ email: mailToBeRegistered }) }
-              return user;
-            });
-        }
-        else if (isSameEmailTreatedAsIdenticalUser) {
-          promise = User.findOne({ email: mailToBeRegistered });
-        }
-
-        return promise
-          .then((user) => {
-            // when the User that have the same `username` exists
-            if (user != null) {
-              throw new DuplicatedUsernameException(`User '${usernameToBeRegistered}' already exists`, user);
-            }
-            if (nameToBeRegistered == null) {
-              // eslint-disable-next-line no-param-reassign
-              nameToBeRegistered = '';
-            }
-
-            // create a new User with STATUS_ACTIVE
-            debug(`ExternalAccount '${accountId}' is not found, it is going to be registered.`);
-            return User.createUser(nameToBeRegistered, usernameToBeRegistered, mailToBeRegistered, undefined, undefined, User.STATUS_ACTIVE);
-          })
-          .then((newUser) => {
-            return this.associate(providerType, accountId, newUser);
-          });
-      });
-  }
-
-  /**
-   * Create ExternalAccount document and associate to existing User
-   *
-   * @param {string} providerType
-   * @param {string} accountId
-   * @param {object} user
-   */
-  static associate(providerType, accountId, user) {
-    return this.create({ providerType, accountId, user: user._id });
-  }
-
-  /**
-   * find all entities with pagination
-   *
-   * @see https://github.com/edwardhotchkiss/mongoose-paginate
-   *
-   * @static
-   * @param {any} opts mongoose-paginate options object
-   * @returns {Promise<any>} mongoose-paginate result object
-   * @memberof ExternalAccount
-   */
-  static findAllWithPagination(opts) {
-    const query = {};
-    const options = Object.assign({ populate: 'user' }, opts);
-    if (options.sort == null) {
-      options.sort = { accountId: 1, createdAt: 1 };
-    }
-    if (options.limit == null) {
-      options.limit = ExternalAccount.DEFAULT_LIMIT;
-    }
-
-    return this.paginate(query, options);
-  }
-
-}
-
-module.exports = function(crowi) {
-  ExternalAccount.crowi = crowi;
-  schema.loadClass(ExternalAccount);
-  return mongoose.model('ExternalAccount', schema);
-};

+ 151 - 0
apps/app/src/server/models/external-account.ts

@@ -0,0 +1,151 @@
+// disable no-return-await for model functions
+/* eslint-disable no-return-await */
+import type { IExternalAccount, IExternalAccountHasId, IUserHasId } from '@growi/core';
+import { Schema, Model, Document } from 'mongoose';
+
+import { NullUsernameToBeRegisteredError } from '~/server/models/errors';
+
+import { getOrCreateModel } from '../util/mongoose-utils';
+
+const debug = require('debug')('growi:models:external-account');
+const mongoose = require('mongoose');
+const mongoosePaginate = require('mongoose-paginate-v2');
+const uniqueValidator = require('mongoose-unique-validator');
+
+const ObjectId = mongoose.Schema.Types.ObjectId;
+
+export interface ExternalAccountDocument extends IExternalAccount, Document {}
+
+export interface ExternalAccountModel extends Model<ExternalAccountDocument> {
+  [x:string]: any, // for old methods
+}
+
+const schema = new Schema<ExternalAccountDocument, ExternalAccountModel>({
+  providerType: { type: String, required: true },
+  accountId: { type: String, required: true },
+  user: { type: ObjectId, ref: 'User', required: true },
+}, {
+  timestamps: { createdAt: true, updatedAt: false },
+});
+// compound index
+schema.index({ providerType: 1, accountId: 1 }, { unique: true });
+// apply plugins
+schema.plugin(mongoosePaginate);
+schema.plugin(uniqueValidator);
+
+/**
+ * limit items num for pagination
+ */
+const DEFAULT_LIMIT = 50;
+
+/**
+ * The Exception class thrown when User.username is duplicated when creating user
+ *
+ * @class DuplicatedUsernameException
+ */
+class DuplicatedUsernameException {
+
+  name: string;
+
+  message: string;
+
+  user: IUserHasId;
+
+  constructor(message, user) {
+    this.name = this.constructor.name;
+    this.message = message;
+    this.user = user;
+  }
+
+}
+
+/**
+ * find an account or register if not found
+ */
+schema.statics.findOrRegister = function(
+    isSameUsernameTreatedAsIdenticalUser: boolean,
+    isSameEmailTreatedAsIdenticalUser: boolean,
+    providerType: string,
+    accountId: string,
+    usernameToBeRegistered?: string,
+    nameToBeRegistered?: string,
+    mailToBeRegistered?: string,
+): Promise<IExternalAccountHasId> {
+  return this.findOne({ providerType, accountId })
+    .then((account) => {
+    // ExternalAccount is found
+      if (account != null) {
+        debug(`ExternalAccount '${accountId}' is found `, account);
+        return account;
+      }
+
+      if (usernameToBeRegistered == null) {
+        throw new NullUsernameToBeRegisteredError('username_should_not_be_null');
+      }
+
+      const User = mongoose.model('User');
+
+      let promise = User.findOne({ username: usernameToBeRegistered });
+      if (isSameUsernameTreatedAsIdenticalUser && isSameEmailTreatedAsIdenticalUser) {
+        promise = promise
+          .then((user) => {
+            if (user == null) { return User.findOne({ email: mailToBeRegistered }) }
+            return user;
+          });
+      }
+      else if (isSameEmailTreatedAsIdenticalUser) {
+        promise = User.findOne({ email: mailToBeRegistered });
+      }
+
+      return promise
+        .then((user) => {
+        // when the User that have the same `username` exists
+          if (user != null) {
+            throw new DuplicatedUsernameException(`User '${usernameToBeRegistered}' already exists`, user);
+          }
+          if (nameToBeRegistered == null) {
+          // eslint-disable-next-line no-param-reassign
+            nameToBeRegistered = '';
+          }
+
+          // create a new User with STATUS_ACTIVE
+          debug(`ExternalAccount '${accountId}' is not found, it is going to be registered.`);
+          return User.createUser(nameToBeRegistered, usernameToBeRegistered, mailToBeRegistered, undefined, undefined, User.STATUS_ACTIVE);
+        })
+        .then((newUser) => {
+          return this.associate(providerType, accountId, newUser);
+        });
+    });
+};
+
+/**
+ * Create ExternalAccount document and associate to existing User
+ */
+schema.statics.associate = function(providerType: string, accountId: string, user: IUserHasId) {
+  return this.create({ providerType, accountId, user: user._id });
+};
+
+/**
+ * find all entities with pagination
+ *
+ * @see https://github.com/edwardhotchkiss/mongoose-paginate
+ *
+ * @static
+ * @param {any} opts mongoose-paginate options object
+ * @returns {Promise<any>} mongoose-paginate result object
+ * @memberof ExternalAccount
+ */
+schema.statics.findAllWithPagination = function(opts) {
+  const query = {};
+  const options = Object.assign({ populate: 'user' }, opts);
+  if (options.sort == null) {
+    options.sort = { accountId: 1, createdAt: 1 };
+  }
+  if (options.limit == null) {
+    options.limit = DEFAULT_LIMIT;
+  }
+
+  return this.paginate(query, options);
+};
+
+export default getOrCreateModel<ExternalAccountDocument, ExternalAccountModel>('ExternalAccount', schema);

+ 0 - 3
apps/app/src/server/models/index.js

@@ -4,10 +4,7 @@ module.exports = {
   Page,
   PageTagRelation: require('./page-tag-relation'),
   User: require('./user'),
-  ExternalAccount: require('./external-account'),
-  UserGroupRelation: require('./user-group-relation'),
   Revision: require('./revision'),
-  Tag: require('./tag'),
   Bookmark: require('./bookmark'),
   Comment: require('./comment'),
   Attachment: require('./attachment'),

+ 4 - 4
apps/app/src/server/models/interfaces/page-operation.ts

@@ -1,4 +1,4 @@
-import { PageGrant } from '@growi/core';
+import { PageGrant, type IGrantedGroup } from '@growi/core';
 
 import { ObjectIdLike } from '../../interfaces/mongoose-utils';
 
@@ -9,7 +9,7 @@ export type IPageForResuming = {
   parent?: ObjectIdLike,
   grant?: number,
   grantedUsers?: ObjectIdLike[],
-  grantedGroup?: ObjectIdLike,
+  grantedGroups: IGrantedGroup[],
   descendantCount: number,
   status?: number,
   revision?: ObjectIdLike,
@@ -23,14 +23,14 @@ export type IUserForResuming = {
 
 export type IOptionsForUpdate = {
   grant?: PageGrant,
-  grantUserGroupId?: ObjectIdLike,
+  grantUserGroupIds?: IGrantedGroup[],
   isSyncRevisionToHackmd?: boolean,
   overwriteScopesOfDescendants?: boolean,
 };
 
 export type IOptionsForCreate = {
   format?: string,
-  grantUserGroupId?: ObjectIdLike,
+  grantUserGroupIds?: IGrantedGroup[],
   grant?: PageGrant,
   overwriteScopesOfDescendants?: boolean,
   isSynchronously?: boolean,

+ 48 - 49
apps/app/src/server/models/obsolete-page.js

@@ -1,20 +1,24 @@
-import { PageGrant } from '@growi/core';
+import { PageGrant, GroupType } from '@growi/core';
 import { templateChecker, pagePathUtils, pathUtils } from '@growi/core/dist/utils';
 import escapeStringRegexp from 'escape-string-regexp';
 
+import ExternalUserGroup from '~/features/external-user-group/server/models/external-user-group';
+import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import loggerFactory from '~/utils/logger';
 
+import UserGroup from './user-group';
+import UserGroupRelation from './user-group-relation';
+
 
 // disable no-return-await for model functions
 /* eslint-disable no-return-await */
 
 /* eslint-disable no-use-before-define */
 
-const debug = require('debug')('growi:models:page');
-
 const nodePath = require('path');
 
 const differenceInYears = require('date-fns/differenceInYears');
+const debug = require('debug')('growi:models:page');
 const mongoose = require('mongoose');
 const urljoin = require('url-join');
 
@@ -69,12 +73,12 @@ export const extractToAncestorsPaths = (pagePath) => {
 export const populateDataToShowRevision = (page, userPublicFields, shouldExcludeBody = false) => {
   return page
     .populate([
-      { path: 'lastUpdateUser', model: 'User', select: userPublicFields },
-      { path: 'creator', model: 'User', select: userPublicFields },
-      { path: 'deleteUser', model: 'User', select: userPublicFields },
-      { path: 'grantedGroup', model: 'UserGroup' },
-      { path: 'revision', model: 'Revision', select: shouldExcludeBody ? '-body' : undefined, populate: {
-        path: 'author', model: 'User', select: userPublicFields,
+      { path: 'lastUpdateUser', select: userPublicFields },
+      { path: 'creator', select: userPublicFields },
+      { path: 'deleteUser', select: userPublicFields },
+      { path: 'grantedGroups.item' },
+      { path: 'revision', select: shouldExcludeBody ? '-body' : undefined, populate: {
+        path: 'author', select: userPublicFields,
       } },
     ]);
 };
@@ -249,10 +253,10 @@ export const getPageSchema = (crowi) => {
     return this.populate('revision');
   };
 
-  pageSchema.methods.applyScope = function(user, grant, grantUserGroupId) {
+  pageSchema.methods.applyScope = function(user, grant, grantUserGroupIds) {
     // Reset
     this.grantedUsers = [];
-    this.grantedGroup = null;
+    this.grantedGroups = [];
 
     this.grant = grant || GRANT_PUBLIC;
 
@@ -261,7 +265,7 @@ export const getPageSchema = (crowi) => {
     }
 
     if (grant === GRANT_USER_GROUP) {
-      this.grantedGroup = grantUserGroupId;
+      this.grantedGroups = grantUserGroupIds;
     }
   };
 
@@ -319,12 +323,10 @@ export const getPageSchema = (crowi) => {
   pageSchema.statics.isAccessiblePageByViewer = async function(id, user) {
     const baseQuery = this.count({ _id: id });
 
-    let userGroups = [];
-    if (user != null) {
-      validateCrowi();
-      const UserGroupRelation = crowi.model('UserGroupRelation');
-      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-    }
+    const userGroups = user != null ? [
+      ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+      ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+    ] : [];
 
     const queryBuilder = new this.PageQueryBuilder(baseQuery);
     queryBuilder.addConditionToFilteringByViewer(user, userGroups, true);
@@ -341,12 +343,10 @@ export const getPageSchema = (crowi) => {
   pageSchema.statics.findByIdAndViewer = async function(id, user, userGroups, includeEmpty = false) {
     const baseQuery = this.findOne({ _id: id });
 
-    let relatedUserGroups = userGroups;
-    if (user != null && relatedUserGroups == null) {
-      validateCrowi();
-      const UserGroupRelation = crowi.model('UserGroupRelation');
-      relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-    }
+    const relatedUserGroups = (user != null && userGroups == null) ? [
+      ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+      ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+    ] : userGroups;
 
     const queryBuilder = new this.PageQueryBuilder(baseQuery, includeEmpty);
     queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups, true);
@@ -384,12 +384,10 @@ export const getPageSchema = (crowi) => {
     // pick the longest one
     const baseQuery = this.findOne({ path: { $in: ancestorsPaths } }).sort({ path: -1 });
 
-    let relatedUserGroups = userGroups;
-    if (user != null && relatedUserGroups == null) {
-      validateCrowi();
-      const UserGroupRelation = crowi.model('UserGroupRelation');
-      relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-    }
+    const relatedUserGroups = (user != null && userGroups == null) ? [
+      ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+      ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+    ] : userGroups;
 
     const queryBuilder = new this.PageQueryBuilder(baseQuery, includeEmpty);
     queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups);
@@ -513,11 +511,10 @@ export const getPageSchema = (crowi) => {
     const hidePagesRestrictedByGroup = crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup');
 
     // determine UserGroup condition
-    let userGroups = null;
-    if (user != null) {
-      const UserGroupRelation = crowi.model('UserGroupRelation');
-      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-    }
+    const userGroups = user != null ? [
+      ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+      ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+    ] : null;
 
     return builder.addConditionToFilteringByViewer(user, userGroups, showAnyoneKnowsLink, !hidePagesRestrictedByOwner, !hidePagesRestrictedByGroup);
   }
@@ -531,14 +528,11 @@ export const getPageSchema = (crowi) => {
    * @param {boolean} showAnyoneKnowsLink
    */
   async function addConditionToFilteringByViewerToEdit(builder, user) {
-    validateCrowi();
-
     // determine UserGroup condition
-    let userGroups = null;
-    if (user != null) {
-      const UserGroupRelation = crowi.model('UserGroupRelation');
-      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-    }
+    const userGroups = user != null ? [
+      ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+      ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+    ] : null;
 
     return builder.addConditionToFilteringByViewer(user, userGroups, false, false, false);
   }
@@ -663,7 +657,7 @@ export const getPageSchema = (crowi) => {
 
     await builder.query.updateMany({}, {
       grant,
-      grantedGroup: grant === PageGrant.GRANT_USER_GROUP ? parentPage.grantedGroup : null,
+      grantedGroups: grant === PageGrant.GRANT_USER_GROUP ? parentPage.grantedGroups : null,
       grantedUsers: grant === PageGrant.GRANT_OWNER ? [user._id] : null,
     });
 
@@ -682,7 +676,7 @@ export const getPageSchema = (crowi) => {
         updateOne: {
           filter: { _id: page._id },
           update: {
-            grantedGroup: null,
+            grantedGroups: null,
             grant: this.GRANT_PUBLIC,
           },
         },
@@ -691,14 +685,19 @@ export const getPageSchema = (crowi) => {
     await this.bulkWrite(operationsToPublicize);
   };
 
-  pageSchema.statics.transferPagesToGroup = async function(pages, transferToUserGroupId) {
-    const UserGroup = mongoose.model('UserGroup');
+  /**
+   * transfer pages grant to specified user group
+   * @param {Page[]} pages
+   * @param {IGrantedGroup} transferToUserGroup
+   */
+  pageSchema.statics.transferPagesToGroup = async function(pages, transferToUserGroup) {
+    const userGroupModel = transferToUserGroup.type === GroupType.userGroup ? UserGroup : ExternalUserGroup;
 
-    if ((await UserGroup.count({ _id: transferToUserGroupId })) === 0) {
-      throw Error('Cannot find the group to which private pages belong to. _id: ', transferToUserGroupId);
+    if ((await userGroupModel.count({ _id: transferToUserGroup.item })) === 0) {
+      throw Error('Cannot find the group to which private pages belong to. _id: ', transferToUserGroup.item);
     }
 
-    await this.updateMany({ _id: { $in: pages.map(p => p._id) } }, { grantedGroup: transferToUserGroupId });
+    await this.updateMany({ _id: { $in: pages.map(p => p._id) } }, { grantedGroups: [transferToUserGroup] });
   };
 
   /**

+ 23 - 2
apps/app/src/server/models/page-operation.ts

@@ -1,3 +1,4 @@
+import { GroupType } from '@growi/core';
 import { addSeconds } from 'date-fns';
 import mongoose, {
   Schema, Model, Document, QueryOptions, FilterQuery,
@@ -58,7 +59,17 @@ const pageSchemaForResuming = new Schema<IPageForResuming>({
   status: { type: String },
   grant: { type: Number },
   grantedUsers: [{ type: ObjectId, ref: 'User' }],
-  grantedGroup: { type: ObjectId, ref: 'UserGroup' },
+  grantedGroups: [{
+    type: {
+      type: String,
+      enum: Object.values(GroupType),
+      required: true,
+      default: 'UserGroup',
+    },
+    item: {
+      type: ObjectId, refPath: 'grantedGroups.type', required: true,
+    },
+  }],
   creator: { type: ObjectId, ref: 'User' },
   lastUpdateUser: { type: ObjectId, ref: 'User' },
 });
@@ -72,7 +83,17 @@ const optionsSchemaForResuming = new Schema<IOptionsForResuming>({
   updateMetadata: { type: Boolean },
   prevDescendantCount: { type: Number },
   grant: { type: Number },
-  grantUserGroupId: { type: ObjectId, ref: 'UserGroup' },
+  grantUserGroupIds: [{
+    type: {
+      type: String,
+      enum: Object.values(GroupType),
+      required: true,
+      default: 'UserGroup',
+    },
+    item: {
+      type: ObjectId, refPath: 'grantedGroups.type', required: true,
+    },
+  }],
   format: { type: String },
   isSyncRevisionToHackmd: { type: Boolean },
   overwriteScopesOfDescendants: { type: Boolean },

+ 46 - 15
apps/app/src/server/models/page.ts

@@ -3,7 +3,11 @@
 import assert from 'assert';
 import nodePath from 'path';
 
-import type { IPage, HasObjectId } from '@growi/core';
+import {
+  type IPage,
+  type IGrantedGroup,
+  GroupType, type HasObjectId,
+} from '@growi/core';
 import { isPopulated } from '@growi/core/dist/interfaces';
 import { isTopPage, hasSlash, collectAncestorPaths } from '@growi/core/dist/utils/page-path-utils';
 import { addTrailingSlash, normalizePath } from '@growi/core/dist/utils/path-utils';
@@ -14,12 +18,14 @@ import mongoose, {
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 
+import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 
 import loggerFactory from '../../utils/logger';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
+import UserGroupRelation from './user-group-relation';
 
 const logger = loggerFactory('growi:models:page');
 /*
@@ -95,7 +101,28 @@ const schema = new Schema<PageDocument, PageModel>({
   status: { type: String, default: STATUS_PUBLISHED, index: true },
   grant: { type: Number, default: GRANT_PUBLIC, index: true },
   grantedUsers: [{ type: ObjectId, ref: 'User' }],
-  grantedGroup: { type: ObjectId, ref: 'UserGroup', index: true },
+  grantedGroups: {
+    type: [{
+      type: {
+        type: String,
+        enum: Object.values(GroupType),
+        required: true,
+        default: 'UserGroup',
+      },
+      item: {
+        type: ObjectId,
+        refPath: 'grantedGroups.type',
+        required: true,
+        index: true,
+      },
+    }],
+    validate: [function(arr) {
+      if (arr == null) return true;
+      const uniqueItemValues = new Set(arr.map(e => e.item));
+      return arr.length === uniqueItemValues.size;
+    }, 'grantedGroups contains non unique item'],
+    default: [],
+  },
   creator: { type: ObjectId, ref: 'User', index: true },
   lastUpdateUser: { type: ObjectId, ref: 'User' },
   liker: [{ type: ObjectId, ref: 'User' }],
@@ -304,11 +331,10 @@ export class PageQueryBuilder {
 
   async addConditionForParentNormalization(user): Promise<PageQueryBuilder> {
     // determine UserGroup condition
-    let userGroups;
-    if (user != null) {
-      const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
-      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-    }
+    const userGroups = user != null ? [
+      ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+      ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+    ] : null;
 
     const grantConditions: any[] = [
       { grant: null },
@@ -323,7 +349,10 @@ export class PageQueryBuilder {
 
     if (userGroups != null && userGroups.length > 0) {
       grantConditions.push(
-        { grant: GRANT_USER_GROUP, grantedGroup: { $in: userGroups } },
+        {
+          grant: GRANT_USER_GROUP,
+          grantedGroups: { $elemMatch: { item: { $in: userGroups } } },
+        },
       );
     }
 
@@ -353,11 +382,10 @@ export class PageQueryBuilder {
 
   // add viewer condition to PageQueryBuilder instance
   async addViewerCondition(user, userGroups = null, includeAnyoneWithTheLink = false): Promise<PageQueryBuilder> {
-    let relatedUserGroups = userGroups;
-    if (user != null && relatedUserGroups == null) {
-      const UserGroupRelation: any = mongoose.model('UserGroupRelation');
-      relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-    }
+    const relatedUserGroups = (user != null && userGroups == null) ? [
+      ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+      ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+    ] : userGroups;
 
     this.addConditionToFilteringByViewer(user, relatedUserGroups, includeAnyoneWithTheLink);
     return this;
@@ -935,7 +963,10 @@ export function generateGrantCondition(
   }
   else if (userGroups != null && userGroups.length > 0) {
     grantConditions.push(
-      { grant: GRANT_USER_GROUP, grantedGroup: { $in: userGroups } },
+      {
+        grant: GRANT_USER_GROUP,
+        grantedGroups: { $elemMatch: { item: { $in: userGroups } } },
+      },
     );
   }
 
@@ -1015,7 +1046,7 @@ schema.methods.calculateAndUpdateLatestRevisionBodyLength = async function(this:
 
 export type PageCreateOptions = {
   format?: string
-  grantUserGroupId?: ObjectIdLike
+  grantUserGroupIds?: IGrantedGroup[],
   grant?: number
   overwriteScopesOfDescendants?: boolean
 }

+ 0 - 391
apps/app/src/server/models/user-group-relation.js

@@ -1,391 +0,0 @@
-const debug = require('debug')('growi:models:userGroupRelation');
-const mongoose = require('mongoose');
-const mongoosePaginate = require('mongoose-paginate-v2');
-const uniqueValidator = require('mongoose-unique-validator');
-
-const ObjectId = mongoose.Schema.Types.ObjectId;
-
-
-/*
- * define schema
- */
-const schema = new mongoose.Schema({
-  relatedGroup: { type: ObjectId, ref: 'UserGroup', required: true },
-  relatedUser: { type: ObjectId, ref: 'User', required: true },
-}, {
-  timestamps: { createdAt: true, updatedAt: false },
-});
-schema.plugin(mongoosePaginate);
-schema.plugin(uniqueValidator);
-
-
-/**
- * UserGroupRelation Class
- *
- * @class UserGroupRelation
- */
-class UserGroupRelation {
-
-  /**
-   * limit items num for pagination
-   *
-   * @readonly
-   * @static
-   * @memberof UserGroupRelation
-   */
-  static get PAGE_ITEMS() {
-    return 50;
-  }
-
-  static set crowi(crowi) {
-    this._crowi = crowi;
-  }
-
-  static get crowi() {
-    return this._crowi;
-  }
-
-  /**
-   * remove all invalid relations that has reference to unlinked document
-   */
-  static removeAllInvalidRelations() {
-    return this.findAllRelation()
-      .then((relations) => {
-        // filter invalid documents
-        return relations.filter((relation) => {
-          return relation.relatedUser == null || relation.relatedGroup == null;
-        });
-      })
-      .then((invalidRelations) => {
-        const ids = invalidRelations.map((relation) => { return relation._id });
-        return this.deleteMany({ _id: { $in: ids } });
-      });
-  }
-
-  /**
-   * find all user and group relation
-   *
-   * @static
-   * @returns {Promise<UserGroupRelation[]>}
-   * @memberof UserGroupRelation
-   */
-  static findAllRelation() {
-    return this
-      .find()
-      .populate('relatedUser')
-      .populate('relatedGroup')
-      .exec();
-  }
-
-  /**
-   * find all user and group relation of UserGroup
-   *
-   * @static
-   * @param {UserGroup} userGroup
-   * @returns {Promise<UserGroupRelation[]>}
-   * @memberof UserGroupRelation
-   */
-  static findAllRelationForUserGroup(userGroup) {
-    debug('findAllRelationForUserGroup is called', userGroup);
-    return this
-      .find({ relatedGroup: userGroup })
-      .populate('relatedUser')
-      .exec();
-  }
-
-  static async findAllUserIdsForUserGroup(userGroup) {
-    const relations = await this
-      .find({ relatedGroup: userGroup })
-      .select('relatedUser')
-      .exec();
-
-    return relations.map(r => r.relatedUser);
-  }
-
-  /**
-   * find all user and group relation of UserGroups
-   *
-   * @static
-   * @param {UserGroup[]} userGroups
-   * @returns {Promise<UserGroupRelation[]>}
-   * @memberof UserGroupRelation
-   */
-  static findAllRelationForUserGroups(userGroups) {
-    return this
-      .find({ relatedGroup: { $in: userGroups } })
-      .populate('relatedUser')
-      .exec();
-  }
-
-  /**
-   * find all user and group relation of User
-   *
-   * @static
-   * @param {User} user
-   * @returns {Promise<UserGroupRelation[]>}
-   * @memberof UserGroupRelation
-   */
-  static findAllRelationForUser(user) {
-    return this
-      .find({ relatedUser: user.id })
-      .populate('relatedGroup')
-      // filter documents only relatedGroup is not null
-      .then((userGroupRelations) => {
-        return userGroupRelations.filter((relation) => {
-          return relation.relatedGroup != null;
-        });
-      });
-  }
-
-  /**
-   * find all UserGroup IDs that related to specified User
-   *
-   * @static
-   * @param {User} user
-   * @returns {Promise<ObjectId[]>}
-   */
-  static async findAllUserGroupIdsRelatedToUser(user) {
-    const relations = await this.find({ relatedUser: user._id })
-      .select('relatedGroup')
-      .exec();
-
-    return relations.map((relation) => { return relation.relatedGroup });
-  }
-
-  /**
-   * count by related group id and related user
-   *
-   * @static
-   * @param {string} userGroupId find query param for relatedGroup
-   * @param {User} userData find query param for relatedUser
-   * @returns {Promise<number>}
-   */
-  static async countByGroupIdAndUser(userGroupId, userData) {
-    const query = {
-      relatedGroup: userGroupId,
-      relatedUser: userData.id,
-    };
-
-    return this.count(query);
-  }
-
-  /**
-   * find all "not" related user for UserGroup
-   *
-   * @static
-   * @param {UserGroup} userGroup for find users not related
-   * @returns {Promise<User>}
-   * @memberof UserGroupRelation
-   */
-  static findUserByNotRelatedGroup(userGroup, queryOptions) {
-    const User = UserGroupRelation.crowi.model('User');
-    let searchWord = new RegExp(`${queryOptions.searchWord}`);
-    switch (queryOptions.searchType) {
-      case 'forward':
-        searchWord = new RegExp(`^${queryOptions.searchWord}`);
-        break;
-      case 'backword':
-        searchWord = new RegExp(`${queryOptions.searchWord}$`);
-        break;
-    }
-    const searthField = [
-      { username: searchWord },
-    ];
-    if (queryOptions.isAlsoMailSearched === 'true') { searthField.push({ email: searchWord }) }
-    if (queryOptions.isAlsoNameSearched === 'true') { searthField.push({ name: searchWord }) }
-
-    return this.findAllRelationForUserGroup(userGroup)
-      .then((relations) => {
-        const relatedUserIds = relations.map((relation) => {
-          return relation.relatedUser.id;
-        });
-        const query = {
-          _id: { $nin: relatedUserIds },
-          status: User.STATUS_ACTIVE,
-          $or: searthField,
-        };
-
-        debug('findUserByNotRelatedGroup ', query);
-        return User.find(query).exec();
-      });
-  }
-
-  /**
-   * get if the user has relation for group
-   *
-   * @static
-   * @param {UserGroup} userGroup
-   * @param {User} user
-   * @returns {Promise<boolean>} is user related for group(or not)
-   * @memberof UserGroupRelation
-   */
-  static isRelatedUserForGroup(userGroup, user) {
-    const query = {
-      relatedGroup: userGroup.id,
-      relatedUser: user.id,
-    };
-
-    return this
-      .count(query)
-      .exec()
-      .then((count) => {
-        // return true or false of the relation is exists(not count)
-        return (count > 0);
-      });
-  }
-
-  /**
-   * create user and group relation
-   *
-   * @static
-   * @param {UserGroup} userGroup
-   * @param {User} user
-   * @returns {Promise<UserGroupRelation>} created relation
-   * @memberof UserGroupRelation
-   */
-  static createRelation(userGroup, user) {
-    return this.create({
-      relatedGroup: userGroup.id,
-      relatedUser: user.id,
-    });
-  }
-
-  static async createRelations(userGroupIds, user) {
-    const documentsToInsertMany = userGroupIds.map((groupId) => {
-      return {
-        relatedGroup: groupId,
-        relatedUser: user._id,
-        createdAt: new Date(),
-      };
-    });
-
-    return this.insertMany(documentsToInsertMany);
-  }
-
-  /**
-   * remove all relation for UserGroup
-   *
-   * @static
-   * @param {UserGroup} userGroup related group for remove
-   * @returns {Promise<any>}
-   * @memberof UserGroupRelation
-   */
-  static removeAllByUserGroups(groupsToDelete) {
-    if (!Array.isArray(groupsToDelete)) {
-      throw Error('groupsToDelete must be an array.');
-    }
-
-    return this.deleteMany({ relatedGroup: { $in: groupsToDelete } });
-  }
-
-  /**
-   * remove relation by id
-   *
-   * @static
-   * @param {ObjectId} id
-   * @returns {Promise<any>}
-   * @memberof UserGroupRelation
-   */
-  static removeById(id) {
-    return this.findById(id)
-      .then((relationData) => {
-        if (relationData == null) {
-          throw new Error('UserGroupRelation data is not exists. id:', id);
-        }
-        else {
-          relationData.remove();
-        }
-      });
-  }
-
-  static async findUserIdsByGroupId(groupId) {
-    const relations = await this.find({ relatedGroup: groupId }, { _id: 0, relatedUser: 1 }).lean().exec(); // .lean() to get not ObjectId but string
-
-    return relations.map(relation => relation.relatedUser);
-  }
-
-  static async createByGroupIdsAndUserIds(groupIds, userIds) {
-    const insertOperations = [];
-
-    groupIds.forEach((groupId) => {
-      userIds.forEach((userId) => {
-        insertOperations.push({
-          insertOne: {
-            document: {
-              relatedGroup: groupId,
-              relatedUser: userId,
-            },
-          },
-        });
-      });
-    });
-
-    await this.bulkWrite(insertOperations);
-  }
-
-  /**
-   * Recursively finds descendant groups by populating relations.
-   * @static
-   * @param {UserGroupDocument[]} groups
-   * @param {UserDocument} user
-   * @returns UserGroupDocument[]
-   */
-  static async findGroupsWithDescendantsByGroupAndUser(group, user) {
-    const descendantGroups = [group];
-
-    const incrementGroupsRecursively = async(groups, user) => {
-      const groupIds = groups.map(g => g._id);
-
-      const populatedRelations = await this.aggregate([
-        {
-          $match: {
-            relatedUser: user._id,
-          },
-        },
-        {
-          $lookup: {
-            from: 'usergroups',
-            localField: 'relatedGroup',
-            foreignField: '_id',
-            as: 'relatedGroup',
-          },
-        },
-        {
-          $unwind: {
-            path: '$relatedGroup',
-          },
-        },
-        {
-          $match: {
-            'relatedGroup.parent': { $in: groupIds },
-          },
-        },
-      ]);
-
-      const nextGroups = populatedRelations.map(d => d.relatedGroup);
-
-      // End
-      const shouldEnd = nextGroups.length === 0;
-      if (shouldEnd) {
-        return;
-      }
-
-      // Increment
-      descendantGroups.push(...nextGroups);
-
-      return incrementGroupsRecursively(nextGroups, user);
-    };
-
-    await incrementGroupsRecursively([group], user);
-
-    return descendantGroups;
-  }
-
-}
-
-module.exports = function(crowi) {
-  UserGroupRelation.crowi = crowi;
-  schema.loadClass(UserGroupRelation);
-  const model = mongoose.model('UserGroupRelation', schema);
-  return model;
-};

+ 374 - 0
apps/app/src/server/models/user-group-relation.ts

@@ -0,0 +1,374 @@
+import { isPopulated, type IUserGroupHasId, type IUserGroupRelation } from '@growi/core';
+import mongoose, { Model, Schema, Document } from 'mongoose';
+
+import { ObjectIdLike } from '../interfaces/mongoose-utils';
+import { getOrCreateModel } from '../util/mongoose-utils';
+
+import { UserGroupDocument } from './user-group';
+
+const debug = require('debug')('growi:models:userGroupRelation');
+const mongoosePaginate = require('mongoose-paginate-v2');
+const uniqueValidator = require('mongoose-unique-validator');
+
+const ObjectId = Schema.Types.ObjectId;
+
+export interface UserGroupRelationDocument extends IUserGroupRelation, Document {}
+
+export interface UserGroupRelationModel extends Model<UserGroupRelationDocument> {
+  [x:string]: any, // for old methods
+
+  PAGE_ITEMS: 50,
+
+  removeAllByUserGroups: (groupsToDelete: UserGroupDocument[]) => Promise<any>,
+
+  findAllUserIdsForUserGroups: (userGroupIds: ObjectIdLike[]) => Promise<string[]>,
+
+  findGroupsWithDescendantsByGroupAndUser: (group: UserGroupDocument, user) => Promise<UserGroupDocument[]>,
+
+  countByGroupIdsAndUser: (userGroupIds: ObjectIdLike[], userData) => Promise<number>
+
+  findAllGroupsForUser: (user) => Promise<UserGroupDocument[]>
+}
+
+/*
+ * define schema
+ */
+const schema = new Schema<UserGroupRelationDocument, UserGroupRelationModel>({
+  relatedGroup: { type: ObjectId, ref: 'UserGroup', required: true },
+  relatedUser: { type: ObjectId, ref: 'User', required: true },
+}, {
+  timestamps: { createdAt: true, updatedAt: false },
+});
+schema.plugin(mongoosePaginate);
+schema.plugin(uniqueValidator);
+
+/**
+ * remove all invalid relations that has reference to unlinked document
+ */
+schema.statics.removeAllInvalidRelations = function() {
+  return this.findAllRelation()
+    .then((relations) => {
+      // filter invalid documents
+      return relations.filter((relation) => {
+        return relation.relatedUser == null || relation.relatedGroup == null;
+      });
+    })
+    .then((invalidRelations) => {
+      const ids = invalidRelations.map((relation) => { return relation._id });
+      return this.deleteMany({ _id: { $in: ids } });
+    });
+};
+
+/**
+   * find all user and group relation
+   *
+   * @static
+   * @returns {Promise<UserGroupRelation[]>}
+   * @memberof UserGroupRelation
+   */
+schema.statics.findAllRelation = function() {
+  return this
+    .find()
+    .populate('relatedUser')
+    .populate('relatedGroup')
+    .exec();
+};
+
+/**
+ * find all user and group relation of UserGroup
+ *
+ * @static
+ * @param {UserGroup} userGroup
+ * @returns {Promise<UserGroupRelation[]>}
+ * @memberof UserGroupRelation
+ */
+schema.statics.findAllRelationForUserGroup = function(userGroup) {
+  debug('findAllRelationForUserGroup is called', userGroup);
+  return this
+    .find({ relatedGroup: userGroup })
+    .populate('relatedUser')
+    .exec();
+};
+
+schema.statics.findAllUserIdsForUserGroups = async function(userGroupIds: ObjectIdLike[]): Promise<string[]> {
+  const relations = await this
+    .find({ relatedGroup: { $in: userGroupIds } })
+    .select('relatedUser')
+    .exec();
+
+  // return unique ids
+  return [...new Set(relations.map(r => r.relatedUser.toString()))];
+};
+
+/**
+ * find all user and group relation of UserGroups
+ *
+ * @static
+ * @param {UserGroup[]} userGroups
+ * @returns {Promise<UserGroupRelation[]>}
+ * @memberof UserGroupRelation
+ */
+schema.statics.findAllRelationForUserGroups = function(userGroups) {
+  return this
+    .find({ relatedGroup: { $in: userGroups } })
+    .populate('relatedUser')
+    .exec();
+};
+
+/**
+ * find all groups of User
+ *
+ * @static
+ * @param {User} user
+ * @returns {Promise<UserGroupDocument[]>}
+ * @memberof UserGroupRelation
+ */
+schema.statics.findAllGroupsForUser = async function(user): Promise<UserGroupDocument[]> {
+  const userGroupRelations = await this.find({ relatedUser: user.id }).populate('relatedGroup');
+  const userGroups = userGroupRelations.map((relation) => {
+    return isPopulated(relation.relatedGroup) ? relation.relatedGroup as UserGroupDocument : null;
+  });
+  return userGroups.filter((group): group is NonNullable<UserGroupDocument> => group != null);
+};
+
+/**
+ * find all UserGroup IDs that related to specified User
+ *
+ * @static
+ * @param {User} user
+ * @returns {Promise<ObjectId[]>}
+ */
+schema.statics.findAllUserGroupIdsRelatedToUser = async function(user) {
+  const relations = await this.find({ relatedUser: user._id })
+    .select('relatedGroup')
+    .exec();
+
+  return relations.map((relation) => { return relation.relatedGroup });
+};
+
+/**
+ * count by related group id and related user
+ *
+ * @static
+ * @param {string} userGroupId find query param for relatedGroup
+ * @param {User} userData find query param for relatedUser
+ * @returns {Promise<number>}
+ */
+schema.statics.countByGroupIdsAndUser = async function(userGroupIds: ObjectIdLike[], userData): Promise<number> {
+  const query = {
+    relatedGroup: { $in: userGroupIds },
+    relatedUser: userData.id,
+  };
+
+  return this.count(query);
+};
+
+/**
+ * find all "not" related user for UserGroup
+ *
+ * @static
+ * @param {UserGroup} userGroup for find users not related
+ * @returns {Promise<User>}
+ * @memberof UserGroupRelation
+ */
+schema.statics.findUserByNotRelatedGroup = function(userGroup, queryOptions) {
+  const User = mongoose.model('User') as any;
+  let searchWord = new RegExp(`${queryOptions.searchWord}`);
+  switch (queryOptions.searchType) {
+    case 'forward':
+      searchWord = new RegExp(`^${queryOptions.searchWord}`);
+      break;
+    case 'backword':
+      searchWord = new RegExp(`${queryOptions.searchWord}$`);
+      break;
+  }
+  const searthField: Record<string, RegExp>[] = [
+    { username: searchWord },
+  ];
+  if (queryOptions.isAlsoMailSearched === 'true') { searthField.push({ email: searchWord }) }
+  if (queryOptions.isAlsoNameSearched === 'true') { searthField.push({ name: searchWord }) }
+
+  return this.findAllRelationForUserGroup(userGroup)
+    .then((relations) => {
+      const relatedUserIds = relations.map((relation) => {
+        return relation.relatedUser.id;
+      });
+      const query = {
+        _id: { $nin: relatedUserIds },
+        status: User.STATUS_ACTIVE,
+        $or: searthField,
+      };
+
+      debug('findUserByNotRelatedGroup ', query);
+      return User.find(query).exec();
+    });
+};
+
+/**
+ * get if the user has relation for group
+ *
+ * @static
+ * @param {UserGroup} userGroup
+ * @param {User} user
+ * @returns {Promise<boolean>} is user related for group(or not)
+ * @memberof UserGroupRelation
+ */
+schema.statics.isRelatedUserForGroup = function(userGroup, user) {
+  const query = {
+    relatedGroup: userGroup.id,
+    relatedUser: user.id,
+  };
+
+  return this
+    .count(query)
+    .exec()
+    .then((count) => {
+      // return true or false of the relation is exists(not count)
+      return (count > 0);
+    });
+};
+
+/**
+ * create user and group relation
+ *
+ * @static
+ * @param {UserGroup} userGroup
+ * @param {User} user
+ * @returns {Promise<UserGroupRelation>} created relation
+ * @memberof UserGroupRelation
+ */
+schema.statics.createRelation = function(userGroup, user) {
+  return this.create({
+    relatedGroup: userGroup.id,
+    relatedUser: user.id,
+  });
+};
+
+schema.statics.createRelations = async function(userGroupIds, user) {
+  const documentsToInsertMany = userGroupIds.map((groupId) => {
+    return {
+      relatedGroup: groupId,
+      relatedUser: user._id,
+      createdAt: new Date(),
+    };
+  });
+
+  return this.insertMany(documentsToInsertMany);
+};
+
+/**
+ * remove all relation for UserGroup
+ *
+ * @static
+ * @param {UserGroup} userGroup related group for remove
+ * @returns {Promise<any>}
+ * @memberof UserGroupRelation
+ */
+schema.statics.removeAllByUserGroups = function(groupsToDelete: UserGroupDocument[]) {
+  return this.deleteMany({ relatedGroup: { $in: groupsToDelete } });
+};
+
+/**
+ * remove relation by id
+ *
+ * @static
+ * @param {ObjectId} id
+ * @returns {Promise<any>}
+ * @memberof UserGroupRelation
+ */
+schema.statics.removeById = function(id) {
+  return this.findById(id)
+    .then((relationData) => {
+      if (relationData == null) {
+        throw new Error('UserGroupRelation data is not exists. id:', id);
+      }
+      else {
+        relationData.remove();
+      }
+    });
+};
+
+schema.statics.findUserIdsByGroupId = async function(groupId) {
+  const relations = await this.find({ relatedGroup: groupId }, { _id: 0, relatedUser: 1 }).lean().exec(); // .lean() to get not ObjectId but string
+
+  return relations.map(relation => relation.relatedUser);
+};
+
+schema.statics.createByGroupIdsAndUserIds = async function(groupIds, userIds) {
+  const insertOperations: any[] = [];
+
+  groupIds.forEach((groupId) => {
+    userIds.forEach((userId) => {
+      insertOperations.push({
+        insertOne: {
+          document: {
+            relatedGroup: groupId,
+            relatedUser: userId,
+          },
+        },
+      });
+    });
+  });
+
+  await this.bulkWrite(insertOperations);
+};
+
+/**
+ * Recursively finds descendant groups by populating relations.
+ * @static
+ * @param {UserGroupDocument} group
+ * @param {UserDocument} user
+ * @returns UserGroupDocument[]
+ */
+schema.statics.findGroupsWithDescendantsByGroupAndUser = async function(group: UserGroupDocument, user): Promise<UserGroupDocument[]> {
+  const descendantGroups = [group];
+
+  const incrementGroupsRecursively = async(groups, user) => {
+    const groupIds = groups.map(g => g._id);
+
+    const populatedRelations = await this.aggregate([
+      {
+        $match: {
+          relatedUser: user._id,
+        },
+      },
+      {
+        $lookup: {
+          from: this.collection.collectionName,
+          localField: 'relatedGroup',
+          foreignField: '_id',
+          as: 'relatedGroup',
+        },
+      },
+      {
+        $unwind: {
+          path: '$relatedGroup',
+        },
+      },
+      {
+        $match: {
+          'relatedGroup.parent': { $in: groupIds },
+        },
+      },
+    ]);
+
+    const nextGroups = populatedRelations.map(d => d.relatedGroup);
+
+    // End
+    const shouldEnd = nextGroups.length === 0;
+    if (shouldEnd) {
+      return;
+    }
+
+    // Increment
+    descendantGroups.push(...nextGroups);
+
+    return incrementGroupsRecursively(nextGroups, user);
+  };
+
+  await incrementGroupsRecursively([group], user);
+
+  return descendantGroups;
+};
+
+export default getOrCreateModel<UserGroupRelationDocument, UserGroupRelationModel>('UserGroupRelation', schema);

+ 12 - 18
apps/app/src/server/models/user-group.ts

@@ -1,7 +1,5 @@
 import type { IUserGroup } from '@growi/core';
-import mongoose, {
-  Schema, Model, Document,
-} from 'mongoose';
+import { Schema, Model, Document } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 
 import { getOrCreateModel } from '../util/mongoose-utils';
@@ -13,12 +11,14 @@ export interface UserGroupModel extends Model<UserGroupDocument> {
   [x:string]: any, // for old methods
 
   PAGE_ITEMS: 10,
+
+  findGroupsWithDescendantsRecursively: (groups, descendants?) => any,
 }
 
 /*
  * define schema
  */
-const ObjectId = mongoose.Schema.Types.ObjectId;
+const ObjectId = Schema.Types.ObjectId;
 
 const schema = new Schema<UserGroupDocument, UserGroupModel>({
   name: { type: String, required: true, unique: true },
@@ -31,7 +31,7 @@ schema.plugin(mongoosePaginate);
 
 const PAGE_ITEMS = 10;
 
-schema.statics.findUserGroupsWithPagination = function(opts) {
+schema.statics.findWithPagination = function(opts) {
   const query = { parent: null };
   const options = Object.assign({}, opts);
   if (options.page == null) {
@@ -48,11 +48,7 @@ schema.statics.findUserGroupsWithPagination = function(opts) {
 };
 
 
-schema.statics.findChildUserGroupsByParentIds = async function(parentIds, includeGrandChildren = false) {
-  if (!Array.isArray(parentIds)) {
-    throw Error('parentIds must be an array.');
-  }
-
+schema.statics.findChildrenByParentIds = async function(parentIds: string[], includeGrandChildren = false) {
   const childUserGroups = await this.find({ parent: { $in: parentIds } });
 
   let grandChildUserGroups: UserGroupDocument[] | null = null;
@@ -72,16 +68,14 @@ schema.statics.countUserGroups = function() {
 };
 
 schema.statics.createGroup = async function(name, description, parentId) {
-  // create without parent
-  if (parentId == null) {
-    return this.create({ name, description });
+  let parent: UserGroupDocument | null = null;
+  if (parentId != null) {
+    parent = await this.findOne({ _id: parentId });
+    if (parent == null) {
+      throw Error('Parent does not exist.');
+    }
   }
 
-  // create with parent
-  const parent = await this.findOne({ _id: parentId });
-  if (parent == null) {
-    throw Error('Parent does not exist.');
-  }
   return this.create({ name, description, parent });
 };
 

+ 0 - 1
apps/app/src/server/routes/admin.js

@@ -5,7 +5,6 @@ const logger = loggerFactory('growi:routes:admin');
 
 /* eslint-disable no-use-before-define */
 module.exports = function(crowi, app) {
-
   const {
     configManager,
     exportService,

+ 4 - 1
apps/app/src/server/routes/apiv3/index.js

@@ -36,6 +36,7 @@ module.exports = (crowi, app) => {
   routerForAdmin.use('/notification-setting', require('./notification-setting')(crowi));
   routerForAdmin.use('/users', require('./users')(crowi));
   routerForAdmin.use('/user-groups', require('./user-group')(crowi));
+  routerForAdmin.use('/external-user-groups', require('~/features/external-user-group/server/routes/apiv3/external-user-group')(crowi));
   routerForAdmin.use('/export', require('./export')(crowi));
   routerForAdmin.use('/import', importRoute(crowi));
   routerForAdmin.use('/search', require('./search')(crowi));
@@ -77,7 +78,7 @@ module.exports = (crowi, app) => {
   router.use('/personal-setting', require('./personal-setting')(crowi));
 
   router.use('/user-group-relations', require('./user-group-relation')(crowi));
-  router.use('/user-group-relations', require('./user-group-relation')(crowi));
+  router.use('/external-user-group-relations', require('~/features/external-user-group/server/routes/apiv3/external-user-group-relation')(crowi));
 
   router.use('/statistics', require('./statistics')(crowi));
 
@@ -117,5 +118,7 @@ module.exports = (crowi, app) => {
   router.use('/questionnaire', require('~/features/questionnaire/server/routes/apiv3/questionnaire')(crowi));
   router.use('/templates', require('~/features/templates/server/routes/apiv3')(crowi));
 
+  router.use('/me', require('./me')(crowi));
+
   return [router, routerForAdmin, routerForAuth];
 };

+ 54 - 0
apps/app/src/server/routes/apiv3/me.ts

@@ -0,0 +1,54 @@
+import { type IUserHasId } from '@growi/core';
+import { Router, Request } from 'express';
+
+import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
+import loggerFactory from '~/utils/logger';
+
+import UserGroupRelation from '../../models/user-group-relation';
+
+import { ApiV3Response } from './interfaces/apiv3-response';
+
+const logger = loggerFactory('growi:routes:apiv3:me');
+
+const router = Router();
+
+interface AuthorizedRequest extends Request {
+  user?: IUserHasId
+}
+
+module.exports = function(crowi) {
+  const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+
+  const ApiResponse = require('../../util/apiResponse');
+
+  /**
+   * retrieve user-group documents
+   */
+  router.get('/user-groups', accessTokenParser, loginRequiredStrictly, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    try {
+      const userGroups = await UserGroupRelation.findAllGroupsForUser(req.user);
+      return res.json(ApiResponse.success({ userGroups }));
+    }
+    catch (e) {
+      logger.error(e);
+      return res.apiv3Err(e, 500);
+    }
+  });
+
+  /**
+   * retrieve external-user-group-relation documents
+   */
+  router.get('/external-user-groups', accessTokenParser, loginRequiredStrictly, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    try {
+      const userGroups = await ExternalUserGroupRelation.findAllGroupsForUser(req.user);
+      return res.json(ApiResponse.success({ userGroups }));
+    }
+    catch (e) {
+      logger.error(e);
+      return res.apiv3Err(e, 500);
+    }
+  });
+
+  return router;
+};

+ 33 - 20
apps/app/src/server/routes/apiv3/page.js

@@ -7,12 +7,14 @@ import { ErrorV3 } from '@growi/core/dist/models';
 import { convertToNewAffiliationPath } from '@growi/core/dist/utils/page-path-utils';
 import sanitize from 'sanitize-filename';
 
+import ExternalUserGroup from '~/features/external-user-group/server/models/external-user-group';
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
 import Subscription from '~/server/models/subscription';
 import UserGroup from '~/server/models/user-group';
+import { divideByType } from '~/server/util/granted-group';
 import loggerFactory from '~/utils/logger';
 
 
@@ -199,7 +201,9 @@ module.exports = (crowi) => {
     updateGrant: [
       param('pageId').isMongoId().withMessage('pageId is required'),
       body('grant').isInt().withMessage('grant is required'),
-      body('grantedGroup').optional().isMongoId().withMessage('grantedGroup must be a mongo id'),
+      body('grantedGroups').optional().isArray().withMessage('grantedGroups must be an array'),
+      body('grantedGroups.*.type').isString().withMessage('grantedGroups type is required'),
+      body('grantedGroups.*.item').isMongoId().withMessage('grantedGroups item is required'),
     ],
     export: [
       query('format').isString().isIn(['md', 'pdf']),
@@ -459,27 +463,30 @@ module.exports = (crowi) => {
     }
 
     const {
-      path, grant, grantedUsers, grantedGroup,
+      path, grant, grantedUsers, grantedGroups,
     } = page;
 
     let isGrantNormalized;
     try {
-      isGrantNormalized = await crowi.pageGrantService.isGrantNormalized(req.user, path, grant, grantedUsers, grantedGroup, false, false);
+      isGrantNormalized = await crowi.pageGrantService.isGrantNormalized(req.user, path, grant, grantedUsers, grantedGroups, false, false);
     }
     catch (err) {
       logger.error('Error occurred while processing isGrantNormalized.', err);
       return res.apiv3Err(err, 500);
     }
 
-    const currentPageUserGroup = await UserGroup.findOne({ _id: grantedGroup });
+    const { grantedUserGroups, grantedExternalUserGroups } = divideByType(grantedGroups);
+    const currentPageUserGroups = await UserGroup.find({ _id: { $in: grantedUserGroups } });
+    const currentPageExternalUserGroups = await ExternalUserGroup.find({ _id: { $in: grantedExternalUserGroups } });
+    const grantedUserGroupData = currentPageUserGroups.map((group) => {
+      return { id: group._id, name: group.name, type: 'UserGroup' };
+    });
+    const grantedExternalUserGroupData = currentPageExternalUserGroups.map((group) => {
+      return { id: group._id, name: group.name, type: 'ExternalUserGroup' };
+    });
     const currentPageGrant = {
       grant,
-      grantedGroup: currentPageUserGroup != null
-        ? {
-          id: currentPageUserGroup._id,
-          name: currentPageUserGroup.name,
-        }
-        : null,
+      grantedGroups: [...grantedUserGroupData, ...grantedExternalUserGroupData],
     };
 
     // page doesn't have parent page
@@ -504,15 +511,21 @@ module.exports = (crowi) => {
       return res.apiv3({ isGrantNormalized, grantData });
     }
 
-    const parentPageUserGroup = await UserGroup.findOne({ _id: parentPage.grantedGroup });
+    const {
+      grantedUserGroups: parentGrantedUserGroupIds,
+      grantedExternalUserGroups: parentGrantedExternalUserGroupIds,
+    } = divideByType(parentPage.grantedGroups);
+    const parentPageUserGroups = await UserGroup.find({ _id: { $in: parentGrantedUserGroupIds } });
+    const parentPageExternalUserGroups = await ExternalUserGroup.find({ _id: { $in: parentGrantedExternalUserGroupIds } });
+    const parentGrantedUserGroupData = parentPageUserGroups.map((group) => {
+      return { id: group._id, name: group.name };
+    });
+    const parentGrantedExternalUserGroupData = parentPageExternalUserGroups.map((group) => {
+      return { id: group._id, name: group.name };
+    });
     const parentPageGrant = {
       grant: parentPage.grant,
-      grantedGroup: parentPageUserGroup != null
-        ? {
-          id: parentPageUserGroup._id,
-          name: parentPageUserGroup.name,
-        }
-        : null,
+      grantedGroups: [...parentGrantedUserGroupData, ...parentGrantedExternalUserGroupData],
     };
 
     const grantData = {
@@ -549,7 +562,7 @@ module.exports = (crowi) => {
 
   router.put('/:pageId/grant', loginRequiredStrictly, excludeReadOnlyUser, validator.updateGrant, apiV3FormValidator, async(req, res) => {
     const { pageId } = req.params;
-    const { grant, grantedGroup } = req.body;
+    const { grant, grantedGroups } = req.body;
 
     const Page = crowi.model('Page');
 
@@ -563,8 +576,8 @@ module.exports = (crowi) => {
     let data;
     try {
       const shouldUseV4Process = false;
-      const grantData = { grant, grantedGroup };
-      data = await this.crowi.pageService.updateGrant(page, req.user, grantData, shouldUseV4Process);
+      const grantData = { grant, grantedGroups };
+      data = await crowi.pageService.updateGrant(page, req.user, grantData, shouldUseV4Process);
     }
     catch (err) {
       logger.error('Error occurred while processing calcApplicableGrantData.', err);

+ 2 - 2
apps/app/src/server/routes/apiv3/pages.js

@@ -295,7 +295,7 @@ module.exports = (crowi) => {
    */
   router.post('/', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity, validator.createPage, apiV3FormValidator, async(req, res) => {
     const {
-      body, grant, grantUserGroupId, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, pageTags,
+      body, grant, grantUserGroupIds, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, pageTags,
     } = req.body;
 
     let { path } = req.body;
@@ -317,7 +317,7 @@ module.exports = (crowi) => {
     const options = { overwriteScopesOfDescendants };
     if (grant != null) {
       options.grant = grant;
-      options.grantUserGroupId = grantUserGroupId;
+      options.grantUserGroupIds = grantUserGroupIds;
     }
 
     const isNoBodyPage = body === undefined;

+ 2 - 1
apps/app/src/server/routes/apiv3/personal-setting.js

@@ -10,6 +10,7 @@ import loggerFactory from '~/utils/logger';
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import EditorSettings from '../../models/editor-settings';
+import ExternalAccount from '../../models/external-account';
 import InAppNotificationSettings from '../../models/in-app-notification-settings';
 
 
@@ -75,7 +76,7 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
-  const { User, ExternalAccount } = crowi.models;
+  const { User } = crowi.models;
 
   const activityEvent = crowi.event('activity');
 

+ 1 - 2
apps/app/src/server/routes/apiv3/user-group-relation.js

@@ -1,5 +1,6 @@
 import { ErrorV3 } from '@growi/core/dist/models';
 
+import UserGroupRelation from '~/server/models/user-group-relation';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:routes:apiv3:user-group-relation'); // eslint-disable-line no-unused-vars
@@ -23,8 +24,6 @@ module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
 
-  const { UserGroupRelation } = crowi.models;
-
   validator.list = [
     query('groupIds', 'groupIds is required and must be an array').isArray(),
     query('childGroupIds', 'childGroupIds must be an array').optional().isArray(),

+ 23 - 13
apps/app/src/server/routes/apiv3/user-group.js

@@ -1,7 +1,11 @@
+import { GroupType } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 
+
 import { SupportedAction } from '~/interfaces/activity';
+import { serializeUserGroupRelationSecurely } from '~/server/models/serializers/user-group-relation-serializer';
 import UserGroup from '~/server/models/user-group';
+import UserGroupRelation from '~/server/models/user-group-relation';
 import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
 import loggerFactory from '~/utils/logger';
 
@@ -36,7 +40,6 @@ module.exports = (crowi) => {
   const activityEvent = crowi.event('activity');
 
   const {
-    UserGroupRelation,
     User,
     Page,
   } = crowi.models;
@@ -108,17 +111,16 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: a result of `UserGroup.find`
    */
-  router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => { // TODO 85062: userGroups with no parent
+  router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
     const { query } = req;
 
-    // TODO 85062: improve sort
     try {
       const page = query.page != null ? parseInt(query.page) : undefined;
       const limit = query.limit != null ? parseInt(query.limit) : undefined;
       const offset = query.offset != null ? parseInt(query.offset) : undefined;
       const pagination = query.pagination != null ? query.pagination !== 'false' : undefined;
 
-      const result = await UserGroup.findUserGroupsWithPagination({
+      const result = await UserGroup.findWithPagination({
         page, limit, offset, pagination,
       });
       const { docs: userGroups, totalDocs: totalUserGroups, limit: pagingLimit } = result;
@@ -176,12 +178,11 @@ module.exports = (crowi) => {
     }
   });
 
-  // TODO 85062: improve sort
   router.get('/children', loginRequiredStrictly, adminRequired, validator.listChildren, async(req, res) => {
     try {
       const { parentIds, includeGrandChildren = false } = req.query;
 
-      const userGroupsResult = await UserGroup.findChildUserGroupsByParentIds(parentIds, includeGrandChildren);
+      const userGroupsResult = await UserGroup.findChildrenByParentIds(parentIds, includeGrandChildren);
       return res.apiv3({
         childUserGroups: userGroupsResult.childUserGroups,
         grandChildUserGroups: userGroupsResult.grandChildUserGroups,
@@ -430,8 +431,13 @@ module.exports = (crowi) => {
     const { id: deleteGroupId } = req.params;
     const { actionName, transferToUserGroupId } = req.query;
 
+    const transferGroupInfo = transferToUserGroupId != null ? {
+      item: transferToUserGroupId,
+      type: GroupType.userGroup,
+    } : undefined;
+
     try {
-      const userGroups = await crowi.userGroupService.removeCompletelyByRootGroupId(deleteGroupId, actionName, transferToUserGroupId, req.user);
+      const userGroups = await crowi.userGroupService.removeCompletelyByRootGroupId(deleteGroupId, actionName, req.user, transferGroupInfo);
 
       const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_DELETE };
       activityEvent.emit('update', res.locals.activity._id, parameters);
@@ -654,13 +660,12 @@ module.exports = (crowi) => {
       const userGroups = await UserGroup.findGroupsWithAncestorsRecursively(userGroup);
       const userGroupIds = userGroups.map(g => g._id);
 
-      // check for duplicate users in groups
+      // remove existing relations from list to create
       const existingRelations = await UserGroupRelation.find({ relatedGroup: { $in: userGroupIds }, relatedUser: user._id });
       const existingGroupIds = existingRelations.map(r => r.relatedGroup);
+      const groupIdsToCreateRelation = excludeTestIdsFromTargetIds(userGroupIds, existingGroupIds);
 
-      const groupIdsOfRelationToCreate = excludeTestIdsFromTargetIds(userGroupIds, existingGroupIds);
-
-      const insertedRelations = await UserGroupRelation.createRelations(groupIdsOfRelationToCreate, user);
+      const insertedRelations = await UserGroupRelation.createRelations(groupIdsToCreateRelation, user);
       const serializedUser = serializeUserSecurely(user);
 
       const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_ADD_USER };
@@ -763,7 +768,8 @@ module.exports = (crowi) => {
     try {
       const userGroup = await UserGroup.findById(id);
       const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
-      return res.apiv3({ userGroupRelations });
+      const serialized = userGroupRelations.map(relation => serializeUserGroupRelationSecurely(relation));
+      return res.apiv3({ userGroupRelations: serialized });
     }
     catch (err) {
       const msg = `Error occurred in fetching user group relations for group: ${id}`;
@@ -810,7 +816,11 @@ module.exports = (crowi) => {
     try {
       const { docs, totalDocs } = await Page.paginate({
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: { $in: [id] },
+        grantedGroups: {
+          $elemMatch: {
+            item: id,
+          },
+        },
       }, {
         offset,
         limit,

+ 4 - 2
apps/app/src/server/routes/apiv3/users.js

@@ -2,8 +2,11 @@
 import { ErrorV3 } from '@growi/core/dist/models';
 import { userHomepagePath } from '@growi/core/dist/utils/page-path-utils';
 
+import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import { SupportedAction } from '~/interfaces/activity';
 import Activity from '~/server/models/activity';
+import ExternalAccount from '~/server/models/external-account';
+import UserGroupRelation from '~/server/models/user-group-relation';
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 
@@ -89,8 +92,6 @@ module.exports = (crowi) => {
   const {
     User,
     Page,
-    ExternalAccount,
-    UserGroupRelation,
   } = crowi.models;
 
 
@@ -814,6 +815,7 @@ module.exports = (crowi) => {
       const homepagePath = userHomepagePath(user);
 
       await UserGroupRelation.remove({ relatedUser: user });
+      await ExternalUserGroupRelation.remove({ relatedUser: user });
       await user.statusDelete();
       await ExternalAccount.remove({ user });
 

+ 0 - 3
apps/app/src/server/routes/index.js

@@ -41,7 +41,6 @@ module.exports = function(crowi, app) {
   const page = require('./page')(crowi, app);
   const login = require('./login')(crowi, app);
   const loginPassport = require('./login-passport')(crowi, app);
-  const me = require('./me')(crowi, app);
   const admin = require('./admin')(crowi, app);
   const attachment = require('./attachment')(crowi, app);
   const comment = require('./comment')(crowi, app);
@@ -122,8 +121,6 @@ module.exports = function(crowi, app) {
 
   apiV1Router.get('/search'                        , accessTokenParser , loginRequired , search.api.search);
 
-  apiV1Router.get('/me/user-group-relations'  , accessTokenParser , loginRequiredStrictly , me.api.userGroupRelations);
-
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
   apiV1Router.get('/pages.list'          , accessTokenParser , loginRequired , page.api.list);
   apiV1Router.post('/pages.update'       , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, page.api.update);

+ 11 - 56
apps/app/src/server/routes/login-passport.js

@@ -3,12 +3,11 @@ import { ErrorV3 } from '@growi/core/dist/models';
 import next from 'next';
 
 import { SupportedAction } from '~/interfaces/activity';
-import { LoginErrorCode } from '~/interfaces/errors/login-error';
 import { ExternalAccountLoginError } from '~/models/vo/external-account-login-error';
-import { NullUsernameToBeRegisteredError } from '~/server/models/errors';
 import { createRedirectToForUnauthenticated } from '~/server/util/createRedirectToForUnauthenticated';
 import loggerFactory from '~/utils/logger';
 
+import { externalAccountService } from '../service/external-account';
 
 /* eslint-disable no-use-before-define */
 
@@ -16,7 +15,6 @@ module.exports = function(crowi, app) {
   const debug = require('debug')('growi:routes:login-passport');
   const logger = loggerFactory('growi:routes:login-passport');
   const passport = require('passport');
-  const ExternalAccount = crowi.model('ExternalAccount');
   const passportService = crowi.passportService;
 
   const activityEvent = crowi.event('activity');
@@ -50,49 +48,6 @@ module.exports = function(crowi, app) {
     });
   };
 
-  const getOrCreateUser = async(req, res, userInfo, providerId) => {
-    // get option
-    const isSameUsernameTreatedAsIdenticalUser = crowi.passportService.isSameUsernameTreatedAsIdenticalUser(providerId);
-    const isSameEmailTreatedAsIdenticalUser = crowi.passportService.isSameEmailTreatedAsIdenticalUser(providerId);
-
-    try {
-      // find or register(create) user
-      const externalAccount = await ExternalAccount.findOrRegister(
-        providerId,
-        userInfo.id,
-        userInfo.username,
-        userInfo.name,
-        userInfo.email,
-        isSameUsernameTreatedAsIdenticalUser,
-        isSameEmailTreatedAsIdenticalUser,
-      );
-      return externalAccount;
-    }
-    catch (err) {
-      /* eslint-disable no-else-return */
-      if (err instanceof NullUsernameToBeRegisteredError) {
-        logger.error(err.message);
-        throw new ErrorV3(err.message);
-      }
-      else if (err.name === 'DuplicatedUsernameException') {
-        if (isSameEmailTreatedAsIdenticalUser || isSameUsernameTreatedAsIdenticalUser) {
-          // associate to existing user
-          debug(`ExternalAccount '${userInfo.username}' will be created and bound to the exisiting User account`);
-          return ExternalAccount.associate(providerId, userInfo.id, err.user);
-        }
-        logger.error('provider-DuplicatedUsernameException', providerId);
-
-        throw new ErrorV3('message.provider_duplicated_username_exception', LoginErrorCode.PROVIDER_DUPLICATED_USERNAME_EXCEPTION,
-          undefined, { failedProviderForDuplicatedUsernameException: providerId });
-      }
-      else if (err.name === 'UserUpperLimitException') {
-        logger.error(err.message);
-        throw new ErrorV3(err.message);
-      }
-      /* eslint-enable no-else-return */
-    }
-  };
-
   /**
    * success handler
    * @param {*} req
@@ -258,7 +213,7 @@ module.exports = function(crowi, app) {
 
     let externalAccount;
     try {
-      externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
+      externalAccount = await externalAccountService.getOrCreateUser(userInfo, providerId);
     }
     catch (error) {
       return next(error);
@@ -269,7 +224,7 @@ module.exports = function(crowi, app) {
       return next(new ErrorV3('message.external_account_not_exist'));
     }
 
-    const user = await externalAccount.getPopulatedUser();
+    const user = (await externalAccount.populate('user')).user;
 
     // login
     await req.logIn(user, (err) => {
@@ -432,12 +387,12 @@ module.exports = function(crowi, app) {
       userInfo.username = userInfo.email.slice(0, userInfo.email.indexOf('@'));
     }
 
-    const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
+    const externalAccount = await externalAccountService.getOrCreateUser(userInfo, providerId);
     if (!externalAccount) {
       return next(new ExternalAccountLoginError('message.sign_in_failure'));
     }
 
-    const user = await externalAccount.getPopulatedUser();
+    const user = (await externalAccount.populate('user')).user;
 
     // login
     req.logIn(user, async(err) => {
@@ -475,12 +430,12 @@ module.exports = function(crowi, app) {
       name: response.displayName,
     };
 
-    const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
+    const externalAccount = await externalAccountService.getOrCreateUser(userInfo, providerId);
     if (!externalAccount) {
       return next(new ExternalAccountLoginError('message.sign_in_failure'));
     }
 
-    const user = await externalAccount.getPopulatedUser();
+    const user = (await externalAccount.populate('user')).user;
 
     // login
     req.logIn(user, async(err) => {
@@ -525,13 +480,13 @@ module.exports = function(crowi, app) {
     };
     debug('mapping response to userInfo', userInfo, response, attrMapId, attrMapUserName, attrMapMail);
 
-    const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
+    const externalAccount = await externalAccountService.getOrCreateUser(userInfo, providerId);
     if (!externalAccount) {
       return new ExternalAccountLoginError('message.sign_in_failure');
     }
 
     // login
-    const user = await externalAccount.getPopulatedUser();
+    const user = (await externalAccount.populate('user')).user;
     req.logIn(user, async(err) => {
       if (err) { debug(err.message); return next(new ExternalAccountLoginError(err.message)) }
 
@@ -584,12 +539,12 @@ module.exports = function(crowi, app) {
       return next(new ExternalAccountLoginError('Sign in failure due to insufficient privileges.'));
     }
 
-    const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
+    const externalAccount = await externalAccountService.getOrCreateUser(userInfo, providerId);
     if (!externalAccount) {
       return next(new ExternalAccountLoginError('message.sign_in_failure'));
     }
 
-    const user = await externalAccount.getPopulatedUser();
+    const user = (await externalAccount.populate('user')).user;
 
     // login
     req.logIn(user, (err) => {

+ 0 - 103
apps/app/src/server/routes/me.js

@@ -1,103 +0,0 @@
-/**
- * @swagger
- *
- *  components:
- *    schemas:
- *      UserGroup:
- *        description: UserGroup
- *        type: object
- *        properties:
- *          __v:
- *            type: number
- *            description: record version
- *            example: 0
- *          _id:
- *            type: string
- *            description: user group ID
- *            example: 5e2d56c1e35da4004ef7e0b0
- *          createdAt:
- *            type: string
- *            description: date created at
- *            example: 2010-01-01T00:00:00.000Z
- */
-
-/**
- * @swagger
- *
- *  components:
- *    schemas:
- *      UserGroupRelation:
- *        description: UserGroupRelation
- *        type: object
- *        properties:
- *          __v:
- *            type: number
- *            description: record version
- *            example: 0
- *          _id:
- *            type: string
- *            description: user group relation ID
- *            example: 5e2d56cbe35da4004ef7e0b1
- *          relatedGroup:
- *            $ref: '#/components/schemas/UserGroup'
- *          relatedUser:
- *            $ref: '#/components/schemas/User/properties/_id'
- *          createdAt:
- *            type: string
- *            description: date created at
- *            example: 2010-01-01T00:00:00.000Z
- */
-
-module.exports = function(crowi, app) {
-  const models = crowi.models;
-  const UserGroupRelation = models.UserGroupRelation;
-  const ApiResponse = require('../util/apiResponse');
-
-  // , pluginService = require('../service/plugin')
-
-  const actions = {};
-
-  const api = {};
-  actions.api = api;
-
-  /**
-   * @swagger
-   *
-   *   /me/user-group-relations:
-   *     get:
-   *       tags: [Me, CrowiCompatibles]
-   *       operationId: getUserGroupRelations
-   *       summary: /me/user-group-relations
-   *       description: Get user group relations
-   *       responses:
-   *         200:
-   *           description: Succeeded to get user group relations.
-   *           content:
-   *             application/json:
-   *               schema:
-   *                 properties:
-   *                   ok:
-   *                     $ref: '#/components/schemas/V1Response/properties/ok'
-   *                   userGroupRelations:
-   *                     type: array
-   *                     items:
-   *                       $ref: '#/components/schemas/UserGroupRelation'
-   *         403:
-   *           $ref: '#/components/responses/403'
-   *         500:
-   *           $ref: '#/components/responses/500'
-   */
-  /**
-   * retrieve user-group-relation documents
-   * @param {object} req
-   * @param {object} res
-   */
-  api.userGroupRelations = function(req, res) {
-    UserGroupRelation.findAllRelationForUser(req.user)
-      .then((userGroupRelations) => {
-        return res.json(ApiResponse.success({ userGroupRelations }));
-      });
-  };
-
-  return actions;
-};

+ 5 - 5
apps/app/src/server/routes/page.js

@@ -324,7 +324,7 @@ module.exports = function(crowi, app) {
     const body = req.body.body || null;
     let pagePath = req.body.path || null;
     const grant = req.body.grant || null;
-    const grantUserGroupId = req.body.grantUserGroupId || null;
+    const grantUserGroupIds = req.body.grantUserGroupIds || null;
     const overwriteScopesOfDescendants = req.body.overwriteScopesOfDescendants || null;
     const isSlackEnabled = !!req.body.isSlackEnabled; // cast to boolean
     const slackChannels = req.body.slackChannels || null;
@@ -346,7 +346,7 @@ module.exports = function(crowi, app) {
     const options = { overwriteScopesOfDescendants };
     if (grant != null) {
       options.grant = grant;
-      options.grantUserGroupId = grantUserGroupId;
+      options.grantUserGroupIds = grantUserGroupIds;
     }
 
     const createdPage = await crowi.pageService.create(pagePath, body, req.user, options);
@@ -451,7 +451,7 @@ module.exports = function(crowi, app) {
     const pageId = req.body.page_id || null;
     const revisionId = req.body.revision_id || null;
     const grant = req.body.grant || null;
-    const grantUserGroupId = req.body.grantUserGroupId || null;
+    const grantUserGroupIds = req.body.grantUserGroupIds || null;
     const overwriteScopesOfDescendants = req.body.overwriteScopesOfDescendants || null;
     const isSlackEnabled = !!req.body.isSlackEnabled; // cast to boolean
     const slackChannels = req.body.slackChannels || null;
@@ -485,7 +485,7 @@ module.exports = function(crowi, app) {
     const options = { isSyncRevisionToHackmd, overwriteScopesOfDescendants };
     if (grant != null) {
       options.grant = grant;
-      options.grantUserGroupId = grantUserGroupId;
+      options.grantUserGroupIds = grantUserGroupIds;
     }
 
     const previousRevision = await Revision.findById(revisionId);
@@ -921,7 +921,7 @@ module.exports = function(crowi, app) {
     req.body.body = page.revision.body;
     req.body.grant = page.grant;
     req.body.grantedUsers = page.grantedUsers;
-    req.body.grantUserGroupId = page.grantedGroup;
+    req.body.grantUserGroupIds = page.grantedGroups;
     req.body.pageTags = originTags;
 
     return api.create(req, res);

+ 6 - 5
apps/app/src/server/routes/search.ts

@@ -1,6 +1,8 @@
+import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 
+import UserGroupRelation from '../models/user-group-relation';
 import { isSearchError } from '../models/vo/search-error';
 
 
@@ -127,11 +129,10 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('SearchService is not reachable.'));
     }
 
-    let userGroups = [];
-    if (user != null) {
-      const UserGroupRelation = crowi.model('UserGroupRelation');
-      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-    }
+    const userGroups = user != null ? [
+      ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+      ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+    ] : null;
 
     const searchOpts = {
       ...paginateOpts, type, sort, order,

+ 72 - 0
apps/app/src/server/service/external-account.ts

@@ -0,0 +1,72 @@
+import { ErrorV3 } from '@growi/core/dist/models';
+
+import { LoginErrorCode } from '~/interfaces/errors/login-error';
+import loggerFactory from '~/utils/logger';
+
+import { NullUsernameToBeRegisteredError } from '../models/errors';
+import ExternalAccount, { ExternalAccountDocument } from '../models/external-account';
+
+import PassportService from './passport';
+
+const logger = loggerFactory('growi:service:external-account-service');
+
+class ExternalAccountService {
+
+  passportService: PassportService;
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  constructor(passportService: PassportService) {
+    this.passportService = passportService;
+  }
+
+  async getOrCreateUser(
+      userInfo: {id: string, username: string, name?: string, email?: string},
+      providerId: string,
+  ): Promise<ExternalAccountDocument | undefined> {
+    // get option
+    const isSameUsernameTreatedAsIdenticalUser = this.passportService.isSameUsernameTreatedAsIdenticalUser(providerId);
+    const isSameEmailTreatedAsIdenticalUser = this.passportService.isSameEmailTreatedAsIdenticalUser(providerId);
+
+    try {
+      // find or register(create) user
+      const externalAccount = await ExternalAccount.findOrRegister(
+        isSameUsernameTreatedAsIdenticalUser,
+        isSameEmailTreatedAsIdenticalUser,
+        providerId,
+        userInfo.id,
+        userInfo.username,
+        userInfo.name,
+        userInfo.email,
+      );
+      return externalAccount;
+    }
+    catch (err) {
+      if (err instanceof NullUsernameToBeRegisteredError) {
+        logger.error(err.message);
+        throw new ErrorV3(err.message);
+      }
+      else if (err.name === 'DuplicatedUsernameException') {
+        if (isSameEmailTreatedAsIdenticalUser || isSameUsernameTreatedAsIdenticalUser) {
+          // associate to existing user
+          logger.debug(`ExternalAccount '${userInfo.username}' will be created and bound to the exisiting User account`);
+          return ExternalAccount.associate(providerId, userInfo.id, err.user);
+        }
+        logger.error('provider-DuplicatedUsernameException', providerId);
+
+        throw new ErrorV3('message.provider_duplicated_username_exception', LoginErrorCode.PROVIDER_DUPLICATED_USERNAME_EXCEPTION,
+          undefined, { failedProviderForDuplicatedUsernameException: providerId });
+      }
+      else if (err.name === 'UserUpperLimitException') {
+        logger.error(err.message);
+        throw new ErrorV3(err.message);
+      }
+    }
+  }
+
+}
+
+// eslint-disable-next-line import/no-mutable-exports
+export let externalAccountService: ExternalAccountService | undefined; // singleton instance
+export function instanciate(passportService: PassportService): void {
+  externalAccountService = new ExternalAccountService(passportService);
+}

+ 173 - 0
apps/app/src/server/service/ldap.ts

@@ -0,0 +1,173 @@
+import ldap, { NoSuchObjectError } from 'ldapjs';
+
+import loggerFactory from '~/utils/logger';
+
+import { configManager } from './config-manager';
+
+
+const logger = loggerFactory('growi:service:ldap-service');
+
+// @types/ldapjs is outdated, and SearchResultEntry does not exist.
+// Declare it manually in the meantime.
+export interface SearchResultEntry {
+  objectName: string // DN
+  attributes: {
+    type: string,
+    values: string | string[]
+  }[]
+}
+
+/**
+ * Service to connect to LDAP server.
+ * User auth using LDAP is done with PassportService, not here.
+*/
+class LdapService {
+
+  client: ldap.Client | null;
+
+  searchBase: string;
+
+  /**
+   * Initialize LDAP client and bind.
+   * @param {string} userBindUsername Necessary when bind type is user bind
+   * @param {string} userBindPassword Necessary when bind type is user bind
+   */
+  initClient(userBindUsername?: string, userBindPassword?: string): void {
+    const serverUrl = configManager?.getConfig('crowi', 'security:passport-ldap:serverUrl');
+
+    // parse serverUrl
+    // see: https://regex101.com/r/0tuYBB/1
+    const match = serverUrl?.match(/(ldaps?:\/\/[^/]+)\/(.*)?/);
+    if (match == null || match.length < 1) {
+      const urlInvalidMessage = 'serverUrl is invalid';
+      logger.error(urlInvalidMessage);
+      throw new Error(urlInvalidMessage);
+    }
+    const url = match[1];
+    this.searchBase = match[2] || '';
+
+    this.client = ldap.createClient({
+      url,
+    });
+    this.bind(userBindUsername, userBindPassword);
+  }
+
+  /**
+   * Bind to LDAP server.
+   * This method is declared independently, so multiple operations can be requested to the LDAP server with a single bind.
+   * @param {string} userBindUsername Necessary when bind type is user bind
+   * @param {string} userBindPassword Necessary when bind type is user bind
+   */
+  bind(userBindUsername?: string, userBindPassword?: string): Promise<void> {
+    const client = this.client;
+    if (client == null) throw new Error('LDAP client is not initialized');
+
+    const isLdapEnabled = configManager?.getConfig('crowi', 'security:passport-ldap:isEnabled');
+    if (!isLdapEnabled) {
+      const notEnabledMessage = 'LDAP is not enabled';
+      logger.error(notEnabledMessage);
+      throw new Error(notEnabledMessage);
+    }
+
+    // get configurations
+    const isUserBind = configManager?.getConfig('crowi', 'security:passport-ldap:isUserBind');
+    const bindDN = configManager?.getConfig('crowi', 'security:passport-ldap:bindDN');
+    const bindCredentials = configManager?.getConfig('crowi', 'security:passport-ldap:bindDNPassword');
+
+    // user bind
+    const fixedBindDN = (isUserBind)
+      ? bindDN.replace(/{{username}}/, userBindUsername)
+      : bindDN;
+    const fixedBindCredentials = (isUserBind) ? userBindPassword : bindCredentials;
+
+    return new Promise<void>((resolve, reject) => {
+      client.bind(fixedBindDN, fixedBindCredentials, (err) => {
+        if (err != null) {
+          reject(err);
+        }
+        resolve();
+      });
+    });
+  }
+
+  /**
+   * Execute search on LDAP server and return result
+   * Execution of bind() is necessary before search
+   * @param {string} filter Search filter
+   * @param {string} base Base DN to execute search on
+   * @returns {SearchEntry[]} Search result. Default scope is set to 'sub'.
+   */
+  search(filter?: string, base?: string, scope: 'sub' | 'base' | 'one' = 'sub'): Promise<SearchResultEntry[]> {
+    const client = this.client;
+    if (client == null) throw new Error('LDAP client is not initialized');
+
+    const searchResults: SearchResultEntry[] = [];
+
+    return new Promise((resolve, reject) => {
+      // reject on client connection error (occures when not binded or host is not found)
+      client.on('error', (err) => {
+        reject(err);
+      });
+
+      client.search(base || this.searchBase, {
+        scope, filter, paged: true, sizeLimit: 200,
+      }, (err, res) => {
+        if (err != null) {
+          reject(err);
+        }
+
+        // @types/ldapjs is outdated, and pojo property (type SearchResultEntry) does not exist.
+        // Typecast to manually declared SearchResultEntry in the meantime.
+        res.on('searchEntry', (entry: any) => {
+          const pojo = entry?.pojo as SearchResultEntry;
+          searchResults.push(pojo);
+        });
+        res.on('error', (err) => {
+          if (err instanceof NoSuchObjectError) {
+            resolve([]);
+          }
+          else {
+            reject(err);
+          }
+        });
+        res.on('end', (result) => {
+          if (result?.status === 0) {
+            resolve(searchResults);
+          }
+          else {
+            reject(new Error(`LDAP search failed: status code ${result?.status}`));
+          }
+        });
+      });
+    });
+  }
+
+  searchGroupDir(): Promise<SearchResultEntry[]> {
+    return this.search(undefined, this.getGroupSearchBase());
+  }
+
+  getArrayValFromSearchResultEntry(entry: SearchResultEntry, attributeType: string): string[] {
+    const values: string | string[] = entry.attributes.find(attribute => attribute.type === attributeType)?.values || [];
+    return typeof values === 'string' ? [values] : values;
+  }
+
+  getStringValFromSearchResultEntry(entry: SearchResultEntry, attributeType: string): string | undefined {
+    const values: string | string[] | undefined = entry.attributes.find(attribute => attribute.type === attributeType)?.values;
+    if (typeof values === 'string' || values == null) {
+      return values;
+    }
+    if (values.length > 0) {
+      return values[0];
+    }
+    return undefined;
+  }
+
+  getGroupSearchBase(): string {
+    return configManager?.getConfig('crowi', 'external-user-group:ldap:groupSearchBase')
+    || configManager?.getConfig('crowi', 'security:passport-ldap:groupSearchBase');
+  }
+
+}
+
+// export the singleton instance
+export const ldapService = new LdapService();

+ 153 - 71
apps/app/src/server/service/page-grant.ts

@@ -1,28 +1,34 @@
 import {
-  PageGrant, type PageGrantCanBeOnTree,
+  type IGrantedGroup,
+  PageGrant, type PageGrantCanBeOnTree, GroupType,
 } from '@growi/core';
 import {
   pagePathUtils, pathUtils, pageUtils,
 } from '@growi/core/dist/utils';
+import { et } from 'date-fns/locale';
 import escapeStringRegexp from 'escape-string-regexp';
 import mongoose from 'mongoose';
 
+import ExternalUserGroup, { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
+import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import { IRecordApplicableGrant } from '~/interfaces/page-grant';
 import { PageDocument, PageModel } from '~/server/models/page';
-import UserGroup from '~/server/models/user-group';
-import { isIncludesObjectId, excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
+import UserGroup, { UserGroupDocument } from '~/server/models/user-group';
+import { includesObjectIds, excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
+
+import { ObjectIdLike } from '../interfaces/mongoose-utils';
+import UserGroupRelation from '../models/user-group-relation';
+import { divideByType } from '../util/granted-group';
 
 const { addTrailingSlash } = pathUtils;
 const { isTopPage } = pagePathUtils;
 
 const LIMIT_FOR_MULTIPLE_PAGE_OP = 20;
 
-type ObjectIdLike = mongoose.Types.ObjectId | string;
-
 type ComparableTarget = {
   grant: number,
   grantedUserIds?: ObjectIdLike[],
-  grantedGroupId?: ObjectIdLike,
+  grantedGroupIds?: IGrantedGroup[],
   applicableUserIds?: ObjectIdLike[],
   applicableGroupIds?: ObjectIdLike[],
 };
@@ -37,7 +43,7 @@ type ComparableAncestor = {
 type ComparableDescendants = {
   isPublicExist: boolean,
   grantedUserIds: ObjectIdLike[],
-  grantedGroupIds: ObjectIdLike[],
+  grantedGroupIds: IGrantedGroup[],
 };
 
 /**
@@ -52,7 +58,6 @@ type UpdateGrantInfo = {
 } | {
   grant: typeof PageGrant.GRANT_USER_GROUP,
   grantedUserGroupInfo: {
-    groupId: ObjectIdLike,
     userIds: Set<ObjectIdLike>,
     childrenOrItselfGroupIds: Set<ObjectIdLike>,
   },
@@ -84,13 +89,13 @@ class PageGrantService {
   private validateComparableTarget(comparable: ComparableTarget) {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
-    const { grant, grantedUserIds, grantedGroupId } = comparable;
+    const { grant, grantedUserIds, grantedGroupIds } = comparable;
 
     if (grant === Page.GRANT_OWNER && (grantedUserIds == null || grantedUserIds.length !== 1)) {
       throw Error('grantedUserIds must not be null and must have 1 length');
     }
-    if (grant === Page.GRANT_USER_GROUP && grantedGroupId == null) {
-      throw Error('grantedGroupId is not specified');
+    if (grant === Page.GRANT_USER_GROUP && grantedGroupIds == null) {
+      throw Error('grantedGroupIds is not specified');
     }
   }
 
@@ -139,17 +144,17 @@ class PageGrantService {
           throw Error('grantedUserIds must have one user');
         }
 
-        if (!isIncludesObjectId(ancestor.applicableUserIds, target.grantedUserIds[0])) { // GRANT_OWNER pages under GRAND_USER_GROUP page must be owned by the member of the grantedGroup of the GRAND_USER_GROUP page
+        if (!includesObjectIds(ancestor.applicableUserIds, [target.grantedUserIds[0]])) { // GRANT_OWNER pages under GRAND_USER_GROUP page must be owned by the member of the grantedGroup of the GRAND_USER_GROUP page
           return false;
         }
       }
 
       if (target.grant === Page.GRANT_USER_GROUP) {
-        if (target.grantedGroupId == null) {
-          throw Error('grantedGroupId must not be null');
+        if (target.grantedGroupIds == null || target.grantedGroupIds.length === 0) {
+          throw Error('grantedGroupId must not be empty');
         }
-
-        if (!isIncludesObjectId(ancestor.applicableGroupIds, target.grantedGroupId)) { // only child groups or the same group can exist under GRANT_USER_GROUP page
+        const targetGrantedGroupStrIds = target.grantedGroupIds.map(e => (typeof e.item === 'string' ? e.item : e.item._id));
+        if (!includesObjectIds(ancestor.applicableGroupIds, targetGrantedGroupStrIds)) { // only child groups or the same group can exist under GRANT_USER_GROUP page
           return false;
         }
       }
@@ -194,7 +199,7 @@ class PageGrantService {
         return false;
       }
 
-      const shouldNotExistGroupIds = excludeTestIdsFromTargetIds(descendants.grantedGroupIds, target.applicableGroupIds);
+      const shouldNotExistGroupIds = excludeTestIdsFromTargetIds(descendants.grantedGroupIds.map(g => g.item), target.applicableGroupIds);
       const shouldNotExistUserIds = excludeTestIdsFromTargetIds(descendants.grantedUserIds, target.applicableUserIds);
       if (shouldNotExistGroupIds.length !== 0 || shouldNotExistUserIds.length !== 0) {
         return false;
@@ -209,32 +214,43 @@ class PageGrantService {
    * @returns Promise<ComparableAncestor>
    */
   private async generateComparableTarget(
-      grant, grantedUserIds: ObjectIdLike[] | undefined, grantedGroupId: ObjectIdLike | undefined, includeApplicable: boolean,
+      grant, grantedUserIds: ObjectIdLike[] | undefined, grantedGroupIds: IGrantedGroup[] | undefined, includeApplicable: boolean,
   ): Promise<ComparableTarget> {
     if (includeApplicable) {
       const Page = mongoose.model('Page') as unknown as PageModel;
-      const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
 
       let applicableUserIds: ObjectIdLike[] | undefined;
       let applicableGroupIds: ObjectIdLike[] | undefined;
 
       if (grant === Page.GRANT_USER_GROUP) {
-        const targetUserGroup = await UserGroup.findOne({ _id: grantedGroupId });
-        if (targetUserGroup == null) {
-          throw Error('Target user group does not exist');
+        if (grantedGroupIds == null || grantedGroupIds.length === 0) {
+          throw Error('Target user group is not given');
         }
 
-        const relatedUsers = await UserGroupRelation.find({ relatedGroup: targetUserGroup._id });
-        applicableUserIds = relatedUsers.map(u => u.relatedUser);
+        const { grantedUserGroups: grantedUserGroupIds, grantedExternalUserGroups: grantedExternalUserGroupIds } = divideByType(grantedGroupIds);
+        const targetUserGroups = await UserGroup.find({ _id: { $in: grantedUserGroupIds } });
+        const targetExternalUserGroups = await ExternalUserGroup.find({ _id: { $in: grantedExternalUserGroupIds } });
+        if (targetUserGroups.length === 0 && targetExternalUserGroups.length === 0) {
+          throw Error('Target user group does not exist');
+        }
 
-        const applicableGroups = grantedGroupId != null ? await UserGroup.findGroupsWithDescendantsById(grantedGroupId) : null;
-        applicableGroupIds = applicableGroups?.map(g => g._id) || null;
+        const userGroupRelations = await UserGroupRelation.find({ relatedGroup: { $in: targetUserGroups.map(g => g._id) } });
+        const externalUserGroupRelations = await ExternalUserGroupRelation.find({ relatedGroup: { $in: targetExternalUserGroups.map(g => g._id) } });
+        applicableUserIds = Array.from(new Set([...userGroupRelations, ...externalUserGroupRelations].map(u => u.relatedUser as ObjectIdLike)));
+
+        const applicableUserGroups = (await Promise.all(targetUserGroups.map((group) => {
+          return UserGroup.findGroupsWithDescendantsById(group._id);
+        }))).flat();
+        const applicableExternalUserGroups = (await Promise.all(targetExternalUserGroups.map((group) => {
+          return ExternalUserGroup.findGroupsWithDescendantsById(group._id);
+        }))).flat();
+        applicableGroupIds = [...applicableUserGroups, ...applicableExternalUserGroups].map(g => g._id);
       }
 
       return {
         grant,
         grantedUserIds,
-        grantedGroupId,
+        grantedGroupIds,
         applicableUserIds,
         applicableGroupIds,
       };
@@ -243,7 +259,7 @@ class PageGrantService {
     return {
       grant,
       grantedUserIds,
-      grantedGroupId,
+      grantedGroupIds,
     };
   }
 
@@ -255,7 +271,6 @@ class PageGrantService {
   private async generateComparableAncestor(targetPath: string, includeNotMigratedPages: boolean): Promise<ComparableAncestor> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const { PageQueryBuilder } = Page;
-    const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
 
     let applicableUserIds: ObjectIdLike[] | undefined;
     let applicableGroupIds: ObjectIdLike[] | undefined;
@@ -279,10 +294,20 @@ class PageGrantService {
 
     if (testAncestor.grant === Page.GRANT_USER_GROUP) {
       // make a set of all users
-      const grantedRelations = await UserGroupRelation.find({ relatedGroup: testAncestor.grantedGroup }, { _id: 0, relatedUser: 1 });
-      const grantedGroups = await UserGroup.findGroupsWithDescendantsById(testAncestor.grantedGroup);
-      applicableGroupIds = grantedGroups.map(g => g._id);
-      applicableUserIds = Array.from(new Set(grantedRelations.map(r => r.relatedUser))) as ObjectIdLike[];
+      const { grantedUserGroups, grantedExternalUserGroups } = divideByType(testAncestor.grantedGroups);
+
+      const userGroupRelations = await UserGroupRelation.find({ relatedGroup: { $in: grantedUserGroups } }, { _id: 0, relatedUser: 1 });
+      const externalUserGroupRelations = await ExternalUserGroupRelation.find({ relatedGroup: { $in: grantedExternalUserGroups } }, { _id: 0, relatedUser: 1 });
+      applicableUserIds = Array.from(new Set([...userGroupRelations, ...externalUserGroupRelations].map(r => r.relatedUser as ObjectIdLike)));
+
+      const applicableUserGroups = (await Promise.all(grantedUserGroups.map((groupId) => {
+        return UserGroup.findGroupsWithDescendantsById(groupId);
+      }))).flat();
+      const applicableExternalUserGroups = (await Promise.all(grantedExternalUserGroups.map((groupId) => {
+        return ExternalUserGroup.findGroupsWithDescendantsById(groupId);
+      }))).flat();
+      applicableGroupIds = [...applicableUserGroups, ...applicableExternalUserGroups].map(g => g._id);
+
     }
 
     return {
@@ -300,7 +325,6 @@ class PageGrantService {
    */
   private async generateComparableDescendants(targetPath: string, user, includeNotMigratedPages = false): Promise<ComparableDescendants> {
     const Page = mongoose.model('Page') as unknown as PageModel;
-    const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
 
     // Build conditions
     const $match: {$or: any} = {
@@ -320,7 +344,10 @@ class PageGrantService {
 
     if (includeNotMigratedPages) {
       // Add grantCondition for not normalized pages
-      const userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+      const userGroups = [
+        ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+        ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+      ];
       const grantCondition = Page.generateGrantCondition(user, userGroups);
       const conditionForNotNormalizedPages = {
         $and: [
@@ -343,21 +370,28 @@ class PageGrantService {
           _id: 0,
           grant: 1,
           grantedUsers: 1,
-          grantedGroup: 1,
+          grantedGroups: 1,
+        },
+      },
+      {
+        $unwind: { // preprocess for creating groups set
+          path: '$grantedGroups',
+          preserveNullAndEmptyArrays: true,
+        },
+      },
+      {
+        $unwind: { // preprocess for creating users set
+          path: '$grantedUsersSet',
+          preserveNullAndEmptyArrays: true,
         },
       },
       { // remove duplicates from pipeline
         $group: {
           _id: '$grant',
-          grantedGroupSet: { $addToSet: '$grantedGroup' },
+          grantedGroupsSet: { $addToSet: '$grantedGroups' },
           grantedUsersSet: { $addToSet: '$grantedUsers' },
         },
       },
-      { // flatten granted user set
-        $unwind: {
-          path: '$grantedUsersSet',
-        },
-      },
     ]);
 
     // GRANT_PUBLIC group
@@ -367,7 +401,7 @@ class PageGrantService {
     const grantedUserIds: ObjectIdLike[] = grantOwnerResult?.grantedUsersSet ?? [];
     // GRANT_USER_GROUP group
     const grantUserGroupResult = result.filter(r => r._id === Page.GRANT_USER_GROUP)[0]; // users of GRANT_OWNER
-    const grantedGroupIds = grantUserGroupResult?.grantedGroupSet ?? [];
+    const grantedGroupIds = grantUserGroupResult?.grantedGroupsSet ?? [];
 
     return {
       isPublicExist,
@@ -385,7 +419,7 @@ class PageGrantService {
    */
   async isGrantNormalized(
       // eslint-disable-next-line max-len
-      user, targetPath: string, grant, grantedUserIds?: ObjectIdLike[], grantedGroupId?: ObjectIdLike, shouldCheckDescendants = false, includeNotMigratedPages = false,
+      user, targetPath: string, grant, grantedUserIds?: ObjectIdLike[], grantedGroupIds?: IGrantedGroup[], shouldCheckDescendants = false, includeNotMigratedPages = false,
   ): Promise<boolean> {
     if (isTopPage(targetPath)) {
       return true;
@@ -394,11 +428,11 @@ class PageGrantService {
     const comparableAncestor = await this.generateComparableAncestor(targetPath, includeNotMigratedPages);
 
     if (!shouldCheckDescendants) { // checking the parent is enough
-      const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupId, false);
+      const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupIds, false);
       return this.processValidation(comparableTarget, comparableAncestor);
     }
 
-    const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupId, true);
+    const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupIds, true);
     const comparableDescendants = await this.generateComparableDescendants(targetPath, user, includeNotMigratedPages);
 
     return this.processValidation(comparableTarget, comparableAncestor, comparableDescendants);
@@ -423,7 +457,7 @@ class PageGrantService {
 
     for await (const page of pages) {
       const {
-        path, grant, grantedUsers: grantedUserIds, grantedGroup: grantedGroupId,
+        path, grant, grantedUsers: grantedUserIds, grantedGroups: grantedGroupIds,
       } = page;
 
       if (!pageUtils.isPageNormalized(page)) {
@@ -431,7 +465,7 @@ class PageGrantService {
         continue;
       }
 
-      if (await this.isGrantNormalized(user, path, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants, shouldIncludeNotMigratedPages)) {
+      if (await this.isGrantNormalized(user, path, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants, shouldIncludeNotMigratedPages)) {
         normalizable.push(page);
       }
       else {
@@ -444,7 +478,6 @@ class PageGrantService {
 
   async calcApplicableGrantData(page, user): Promise<IRecordApplicableGrant> {
     const Page = mongoose.model('Page') as unknown as PageModel;
-    const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
 
     // -- Public only if top page
     const isOnlyPublicApplicable = isTopPage(page.path);
@@ -460,12 +493,14 @@ class PageGrantService {
       [PageGrant.GRANT_RESTRICTED]: null, // any page can be restricted
     };
 
+    const userPossessedGroups = await this.getUserPossessedGroups(user);
+
     // -- Any grant is allowed if parent is null
     const isAnyGrantApplicable = page.parent == null;
     if (isAnyGrantApplicable) {
       data[PageGrant.GRANT_PUBLIC] = null;
       data[PageGrant.GRANT_OWNER] = null;
-      data[PageGrant.GRANT_USER_GROUP] = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+      data[PageGrant.GRANT_USER_GROUP] = { applicableGroups: userPossessedGroups };
       return data;
     }
 
@@ -475,13 +510,13 @@ class PageGrantService {
     }
 
     const {
-      grant, grantedUsers, grantedGroup,
+      grant, grantedUsers, grantedGroups,
     } = parent;
 
     if (grant === PageGrant.GRANT_PUBLIC) {
       data[PageGrant.GRANT_PUBLIC] = null;
       data[PageGrant.GRANT_OWNER] = null;
-      data[PageGrant.GRANT_USER_GROUP] = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+      data[PageGrant.GRANT_USER_GROUP] = { applicableGroups: userPossessedGroups };
     }
     else if (grant === PageGrant.GRANT_OWNER) {
       const grantedUser = grantedUsers[0];
@@ -493,24 +528,59 @@ class PageGrantService {
       }
     }
     else if (grant === PageGrant.GRANT_USER_GROUP) {
-      const group = await UserGroup.findById(grantedGroup);
-      if (group == null) {
+      const { grantedUserGroups: grantedUserGroupIds, grantedExternalUserGroups: grantedExternalUserGroupIds } = divideByType(grantedGroups);
+      const targetUserGroups = await UserGroup.find({ _id: { $in: grantedUserGroupIds } });
+      const targetExternalUserGroups = await ExternalUserGroup.find({ _id: { $in: grantedExternalUserGroupIds } });
+      if (targetUserGroups.length === 0 && targetExternalUserGroups.length === 0) {
         throw Error('Group not found to calculate grant data.');
       }
 
-      const applicableGroups = await UserGroupRelation.findGroupsWithDescendantsByGroupAndUser(group, user);
-
-      const isUserExistInGroup = await UserGroupRelation.countByGroupIdAndUser(group, user) > 0;
+      const isUserExistInUserGroup = (await Promise.all(targetUserGroups.map((group) => {
+        return UserGroupRelation.countByGroupIdsAndUser([group._id], user);
+      }))).some(count => count > 0);
+      const isUserExistInExternalUserGroup = (await Promise.all(targetExternalUserGroups.map((group) => {
+        return ExternalUserGroupRelation.countByGroupIdsAndUser([group._id], user);
+      }))).some(count => count > 0);
+      const isUserExistInGroup = isUserExistInUserGroup || isUserExistInExternalUserGroup;
 
       if (isUserExistInGroup) {
         data[PageGrant.GRANT_OWNER] = null;
       }
+
+      const applicableUserGroups = (await Promise.all(targetUserGroups.map((group) => {
+        return UserGroupRelation.findGroupsWithDescendantsByGroupAndUser(group, user);
+      }))).flat();
+      const applicableExternalUserGroups = (await Promise.all(targetExternalUserGroups.map((group) => {
+        return ExternalUserGroupRelation.findGroupsWithDescendantsByGroupAndUser(group, user);
+      }))).flat();
+
+      const applicableGroups = [
+        ...applicableUserGroups.map((group) => {
+          return { type: GroupType.userGroup, item: group };
+        }),
+        ...applicableExternalUserGroups.map((group) => {
+          return { type: GroupType.externalUserGroup, item: group };
+        }),
+      ];
       data[PageGrant.GRANT_USER_GROUP] = { applicableGroups };
     }
 
     return data;
   }
 
+  async getUserPossessedGroups(user) {
+    const userPossessedUserGroups = await UserGroupRelation.findAllGroupsForUser(user);
+    const userPossessedExternalUserGroups = await ExternalUserGroupRelation.findAllGroupsForUser(user);
+    return [
+      ...userPossessedUserGroups.map((group) => {
+        return { type: GroupType.userGroup, item: group };
+      }),
+      ...userPossessedExternalUserGroups.map((group) => {
+        return { type: GroupType.externalUserGroup, item: group };
+      }),
+    ];
+  }
+
   /**
    * see: https://dev.growi.org/635a314eac6bcd85cbf359fc
    * @param {string} targetPath
@@ -519,9 +589,10 @@ class PageGrantService {
    * @returns {Promise<boolean>}
    */
   async canOverwriteDescendants(targetPath: string, operator: { _id: ObjectIdLike }, updateGrantInfo: UpdateGrantInfo): Promise<boolean> {
-    const UserGroupRelationModel = mongoose.model('UserGroupRelation') as any; // TODO: TypeScriptize model
-
-    const relatedGroupIds = await UserGroupRelationModel.findAllUserGroupIdsRelatedToUser(operator);
+    const relatedGroupIds = [
+      ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(operator)),
+      ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(operator)),
+    ];
     const operatorGrantInfo = {
       userId: operator._id,
       userGroupIds: new Set<ObjectIdLike>(relatedGroupIds),
@@ -542,13 +613,17 @@ class PageGrantService {
     const descendantPagesGrantInfo = {
       grantSet,
       grantedUserIds: new Set(comparableDescendants.grantedUserIds), // all only me users of descendant pages
-      grantedUserGroupIds: new Set(comparableDescendants.grantedGroupIds), // all user groups of descendant pages
+      grantedUserGroupIds: new Set(comparableDescendants.grantedGroupIds.map((g) => {
+        return typeof g.item === 'string' ? g.item : g.item._id;
+      })), // all user groups of descendant pages
     };
 
     return this.calcCanOverwriteDescendants(operatorGrantInfo, updateGrantInfo, descendantPagesGrantInfo);
   }
 
-  async generateUpdateGrantInfoToOverwriteDescendants(operator, updateGrant: PageGrantCanBeOnTree, grantUserGroupId?: ObjectIdLike): Promise<UpdateGrantInfo> {
+  async generateUpdateGrantInfoToOverwriteDescendants(
+      operator, updateGrant: PageGrantCanBeOnTree, grantGroupIds?: IGrantedGroup[],
+  ): Promise<UpdateGrantInfo> {
     let updateGrantInfo: UpdateGrantInfo | null = null;
 
     if (updateGrant === PageGrant.GRANT_PUBLIC) {
@@ -563,18 +638,27 @@ class PageGrantService {
       };
     }
     else if (updateGrant === PageGrant.GRANT_USER_GROUP) {
-      if (grantUserGroupId == null) {
-        throw Error('The parameter `grantUserGroupId` is required.');
+      if (grantGroupIds == null) {
+        throw Error('The parameter `grantGroupIds` is required.');
       }
-      const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
-      const userIds = await UserGroupRelation.findAllUserIdsForUserGroup(grantUserGroupId);
-      const childrenOrItselfGroups = await UserGroup.findGroupsWithDescendantsById(grantUserGroupId);
+      const { grantedUserGroups: grantedUserGroupIds, grantedExternalUserGroups: grantedExternalUserGroupIds } = divideByType(grantGroupIds);
+
+      const userGroupUserIds = await UserGroupRelation.findAllUserIdsForUserGroups(grantedUserGroupIds);
+      const externalUserGroupUserIds = await ExternalUserGroupRelation.findAllUserIdsForUserGroups(grantedExternalUserGroupIds);
+      const userIds = [...userGroupUserIds, ...externalUserGroupUserIds];
+
+      const childrenOrItselfUserGroups = (await Promise.all(grantedUserGroupIds.map((groupId) => {
+        return UserGroup.findGroupsWithDescendantsById(groupId);
+      }))).flat();
+      const childrenOrItselfExternalUserGroups = (await Promise.all(grantedExternalUserGroupIds.map((groupId) => {
+        return ExternalUserGroup.findGroupsWithDescendantsById(groupId);
+      }))).flat();
+      const childrenOrItselfGroups = [...childrenOrItselfUserGroups, ...childrenOrItselfExternalUserGroups];
       const childrenOrItselfGroupIds = childrenOrItselfGroups.map(d => d._id);
 
       updateGrantInfo = {
         grant: PageGrant.GRANT_USER_GROUP,
         grantedUserGroupInfo: {
-          groupId: grantUserGroupId,
           userIds: new Set<ObjectIdLike>(userIds),
           childrenOrItselfGroupIds: new Set<ObjectIdLike>(childrenOrItselfGroupIds),
         },
@@ -591,7 +675,7 @@ class PageGrantService {
   private calcIsAllDescendantsGrantedByOperator(operatorGrantInfo: OperatorGrantInfo, descendantPagesGrantInfo: DescendantPagesGrantInfo): boolean {
     if (descendantPagesGrantInfo.grantSet.has(PageGrant.GRANT_OWNER)) {
       const isNonApplicableOwnerExist = descendantPagesGrantInfo.grantedUserIds.size >= 2
-        || !isIncludesObjectId([...descendantPagesGrantInfo.grantedUserIds], operatorGrantInfo.userId);
+        || !includesObjectIds([...descendantPagesGrantInfo.grantedUserIds], [operatorGrantInfo.userId]);
       if (isNonApplicableOwnerExist) {
         return false;
       }
@@ -601,7 +685,6 @@ class PageGrantService {
       const isNonApplicableGroupExist = excludeTestIdsFromTargetIds(
         [...descendantPagesGrantInfo.grantedUserGroupIds], [...operatorGrantInfo.userGroupIds],
       ).length > 0;
-
       if (isNonApplicableGroupExist) {
         return false;
       }
@@ -639,7 +722,6 @@ class PageGrantService {
       const isUpdateGroupUsersIncludeAllDescendantsOwners = excludeTestIdsFromTargetIds(
         [...descendantPagesGrantInfo.grantedUserIds], [...updateGrantInfo.grantedUserGroupInfo.userIds],
       ).length === 0; // b.
-
       return isAllDescendantGroupsChildrenOrItselfOfUpdateGroup && isUpdateGroupUsersIncludeAllDescendantsOwners;
     }
 

+ 72 - 65
apps/app/src/server/service/page.ts

@@ -3,7 +3,7 @@ import { Readable, Writable } from 'stream';
 
 import type {
   Ref, HasObjectId, IUserHasId,
-  IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta,
+  IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta, IGrantedGroup,
 } from '@growi/core';
 import { PageGrant, PageStatus } from '@growi/core';
 import {
@@ -14,6 +14,7 @@ import escapeStringRegexp from 'escape-string-regexp';
 import mongoose, { ObjectId, Cursor } from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 
+import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import { SupportedAction } from '~/interfaces/activity';
 import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import {
@@ -38,7 +39,9 @@ import { PageRedirectModel } from '../models/page-redirect';
 import { serializePageSecurely } from '../models/serializers/page-serializer';
 import ShareLink from '../models/share-link';
 import Subscription from '../models/subscription';
+import UserGroupRelation from '../models/user-group-relation';
 import { V5ConversionError } from '../models/vo/v5-conversion-error';
+import { divideByType } from '../util/granted-group';
 
 const debug = require('debug')('growi:services:page');
 
@@ -454,7 +457,7 @@ class PageService {
     // use the parent's grant when target page is an empty page
     let grant;
     let grantedUserIds;
-    let grantedGroupId;
+    let grantedGroupIds;
     if (page.isEmpty) {
       const parent = await Page.findOne({ _id: page.parent });
       if (parent == null) {
@@ -462,18 +465,18 @@ class PageService {
       }
       grant = parent.grant;
       grantedUserIds = parent.grantedUsers;
-      grantedGroupId = parent.grantedGroup;
+      grantedGroupIds = parent.grantedGroups;
     }
     else {
       grant = page.grant;
       grantedUserIds = page.grantedUsers;
-      grantedGroupId = page.grantedGroup;
+      grantedGroupIds = page.grantedGroups;
     }
 
     if (grant !== Page.GRANT_RESTRICTED) {
       let isGrantNormalized = false;
       try {
-        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, newPagePath, grant, grantedUserIds, grantedGroupId, false);
+        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, newPagePath, grant, grantedUserIds, grantedGroupIds, false);
       }
       catch (err) {
         logger.error(`Failed to validate grant of page at "${newPagePath}" when renaming`, err);
@@ -961,7 +964,7 @@ class PageService {
     // use the parent's grant when target page is an empty page
     let grant;
     let grantedUserIds;
-    let grantedGroupId;
+    let grantedGroupIds;
     if (page.isEmpty) {
       const parent = await Page.findOne({ _id: page.parent });
       if (parent == null) {
@@ -969,18 +972,18 @@ class PageService {
       }
       grant = parent.grant;
       grantedUserIds = parent.grantedUsers;
-      grantedGroupId = parent.grantedGroup;
+      grantedGroupIds = parent.grantedGroups;
     }
     else {
       grant = page.grant;
       grantedUserIds = page.grantedUsers;
-      grantedGroupId = page.grantedGroup;
+      grantedGroupIds = page.grantedGroups;
     }
 
     if (grant !== Page.GRANT_RESTRICTED) {
       let isGrantNormalized = false;
       try {
-        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, newPagePath, grant, grantedUserIds, grantedGroupId, false);
+        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, newPagePath, grant, grantedUserIds, grantedGroupIds, false);
       }
       catch (err) {
         logger.error(`Failed to validate grant of page at "${newPagePath}" when duplicating`, err);
@@ -997,7 +1000,7 @@ class PageService {
     // 3. Duplicate target
     const options: PageCreateOptions = {
       grant: page.grant,
-      grantUserGroupId: page.grantedGroup,
+      grantUserGroupIds: page.grantedGroups,
     };
     let duplicatedTarget;
     if (page.isEmpty) {
@@ -1109,7 +1112,7 @@ class PageService {
     // create option
     const options: any = { page };
     options.grant = page.grant;
-    options.grantUserGroupId = page.grantedGroup;
+    options.grantUserGroupIds = page.grantedGroups;
     options.grantedUserIds = page.grantedUsers;
 
     newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
@@ -1210,7 +1213,7 @@ class PageService {
           path: newPagePath,
           creator: user._id,
           grant: page.grant,
-          grantedGroup: page.grantedGroup,
+          grantedGroups: page.grantedGroups,
           grantedUsers: page.grantedUsers,
           lastUpdateUser: user._id,
           revision: revisionId,
@@ -1256,7 +1259,7 @@ class PageService {
         path: newPagePath,
         creator: user._id,
         grant: page.grant,
-        grantedGroup: page.grantedGroup,
+        grantedGroups: page.grantedGroups,
         grantedUsers: page.grantedUsers,
         lastUpdateUser: user._id,
         revision: revisionId,
@@ -2359,9 +2362,15 @@ class PageService {
   }
 
 
-  async handlePrivatePagesForGroupsToDelete(groupsToDelete, action, transferToUserGroupId, user) {
+  async handlePrivatePagesForGroupsToDelete(groupsToDelete, action, transferToUserGroup: IGrantedGroup, user) {
     const Page = this.crowi.model('Page');
-    const pages = await Page.find({ grantedGroup: { $in: groupsToDelete } });
+    const pages = await Page.find({
+      grantedGroups: {
+        $elemMatch: {
+          item: { $in: groupsToDelete },
+        },
+      },
+    });
 
     switch (action) {
       case 'public':
@@ -2370,7 +2379,7 @@ class PageService {
       case 'delete':
         return this.deleteMultipleCompletely(pages, user);
       case 'transfer':
-        await Page.transferPagesToGroup(pages, transferToUserGroupId);
+        await Page.transferPagesToGroup(pages, transferToUserGroup);
         break;
       default:
         throw new Error('Unknown action for private pages');
@@ -2423,11 +2432,10 @@ class PageService {
     const MAX_LENGTH = 350;
 
     // aggregation options
-    let userGroups;
-    if (user != null && userGroups == null) {
-      const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // Typescriptize model
-      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-    }
+    const userGroups = user != null ? [
+      ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+      ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+    ] : null;
     const viewerCondition = Page.generateGrantCondition(user, userGroups);
     const filterByIds = {
       _id: { $in: pageIds },
@@ -2532,7 +2540,7 @@ class PageService {
 
       const options: PageCreateOptions & { grantedUsers?: ObjectIdLike[] | undefined } = {
         grant: notEmptyParent.grant,
-        grantUserGroupId: notEmptyParent.grantedGroup,
+        grantUserGroupIds: notEmptyParent.grantedGroups,
         grantedUsers: notEmptyParent.grantedUsers,
       };
 
@@ -2549,7 +2557,7 @@ class PageService {
 
     const grant = page.grant;
     const grantedUserIds = page.grantedUsers;
-    const grantedGroupId = page.grantedGroup;
+    const grantedGroupIds = page.grantedGroups;
 
     /*
      * UserGroup & Owner validation
@@ -2558,7 +2566,7 @@ class PageService {
     try {
       const shouldCheckDescendants = true;
 
-      isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, path, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, path, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
     }
     catch (err) {
       logger.error(`Failed to validate grant of page at "${path}"`, err);
@@ -2658,7 +2666,7 @@ class PageService {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
     const {
-      path, grant, grantedUsers: grantedUserIds, grantedGroup: grantedGroupId,
+      path, grant, grantedUsers: grantedUserIds, grantedGroups: grantedGroupIds,
     } = page;
 
     // check if any page exists at target path already
@@ -2675,7 +2683,7 @@ class PageService {
       try {
         const shouldCheckDescendants = true;
 
-        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, path, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, path, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
       }
       catch (err) {
         logger.error(`Failed to validate grant of page at "${path}"`, err);
@@ -2974,11 +2982,10 @@ class PageService {
     pathAndRegExpsToNormalize.push(...paths);
 
     // determine UserGroup condition
-    let userGroups = null;
-    if (user != null) {
-      const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
-      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-    }
+    const userGroups = user != null ? [
+      ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+      ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+    ] : null;
 
     const grantFiltersByUser: { $or: any[] } = Page.generateGrantCondition(user, userGroups);
 
@@ -3377,11 +3384,10 @@ class PageService {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
     const pipeline = this.buildBasePipelineToCreateEmptyPages(paths, onlyMigratedAsExistingPages, andFilter);
-    let userGroups = null;
-    if (user != null) {
-      const UserGroupRelation = mongoose.model('UserGroupRelation') as any;
-      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-    }
+    const userGroups = user != null ? [
+      ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+      ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+    ] : null;
     const grantCondition = Page.generateGrantCondition(user, userGroups);
     pipeline.push({ $match: grantCondition });
 
@@ -3527,14 +3533,15 @@ class PageService {
     pageDocument.status = Page.STATUS_PUBLISHED;
   }
 
-  private async validateAppliedScope(user, grant, grantUserGroupId) {
-    if (grant === PageGrant.GRANT_USER_GROUP && grantUserGroupId == null) {
-      throw new Error('grant userGroupId is not specified');
+  private async validateAppliedScope(user, grant, grantUserGroupIds: IGrantedGroup[]) {
+    if (grant === PageGrant.GRANT_USER_GROUP && grantUserGroupIds == null) {
+      throw new Error('grantUserGroupIds is not specified');
     }
 
     if (grant === PageGrant.GRANT_USER_GROUP) {
-      const UserGroupRelation = mongoose.model('UserGroupRelation') as any;
-      const count = await UserGroupRelation.countByGroupIdAndUser(grantUserGroupId, user);
+      const { grantedUserGroups: grantedUserGroupIds, grantedExternalUserGroups: grantedExternalUserGroupIds } = divideByType(grantUserGroupIds);
+      const count = await UserGroupRelation.countByGroupIdsAndUser(grantedUserGroupIds, user)
+        + await ExternalUserGroupRelation.countByGroupIdsAndUser(grantedExternalUserGroupIds, user);
 
       if (count === 0) {
         throw new Error('no relations were exist for group and user.');
@@ -3547,7 +3554,7 @@ class PageService {
       grantData: {
         grant: number,
         grantedUserIds?: ObjectIdLike[],
-        grantUserGroupId?: ObjectIdLike,
+        grantUserGroupIds?: IGrantedGroup[],
       },
       shouldValidateGrant: boolean,
       user?,
@@ -3570,7 +3577,7 @@ class PageService {
     }
 
     // UserGroup & Owner validation
-    const { grant, grantedUserIds, grantUserGroupId } = grantData;
+    const { grant, grantedUserIds, grantUserGroupIds } = grantData;
     if (shouldValidateGrant) {
       if (user == null) {
         throw Error('user is required to validate grant');
@@ -3582,7 +3589,7 @@ class PageService {
         const isEmptyPageAlreadyExist = await Page.count({ path, isEmpty: true }) > 0;
         const shouldCheckDescendants = isEmptyPageAlreadyExist && !options?.overwriteScopesOfDescendants;
 
-        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, path, grant, grantedUserIds, grantUserGroupId, shouldCheckDescendants);
+        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, path, grant, grantedUserIds, grantUserGroupIds, shouldCheckDescendants);
       }
       catch (err) {
         logger.error(`Failed to validate grant of page at "${path}" of grant ${grant}:`, err);
@@ -3593,7 +3600,7 @@ class PageService {
       }
 
       if (options?.overwriteScopesOfDescendants) {
-        const updateGrantInfo = await this.crowi.pageGrantService.generateUpdateGrantInfoToOverwriteDescendants(user, grant, options.grantUserGroupId);
+        const updateGrantInfo = await this.crowi.pageGrantService.generateUpdateGrantInfoToOverwriteDescendants(user, grant, options.grantUserGroupIds);
         const canOverwriteDescendants = await this.crowi.pageGrantService.canOverwriteDescendants(path, user, updateGrantInfo);
 
         if (!canOverwriteDescendants) {
@@ -3622,13 +3629,13 @@ class PageService {
     // eslint-disable-next-line no-param-reassign
     path = this.crowi.xss.process(path); // sanitize path
     const {
-      format = 'markdown', grantUserGroupId,
+      format = 'markdown', grantUserGroupIds,
     } = options;
     const grant = isTopPage(path) ? Page.GRANT_PUBLIC : options.grant;
     const grantData = {
       grant,
       grantedUserIds: grant === Page.GRANT_OWNER ? [user._id] : undefined,
-      grantUserGroupId,
+      grantUserGroupIds,
     };
 
     const isGrantRestricted = grant === Page.GRANT_RESTRICTED;
@@ -3648,7 +3655,7 @@ class PageService {
     this.setFieldExceptForGrantRevisionParent(page, path, user);
 
     // Apply scope
-    page.applyScope(user, grant, grantUserGroupId);
+    page.applyScope(user, grant, grantUserGroupIds);
 
     // Set parent
     if (isTopPage(path) || isGrantRestricted) { // set parent to null when GRANT_RESTRICTED
@@ -3733,7 +3740,7 @@ class PageService {
     const Revision = mongoose.model('Revision') as any; // TODO: TypeScriptize model
 
     const format = options.format || 'markdown';
-    const grantUserGroupId = options.grantUserGroupId || null;
+    const grantUserGroupIds = options.grantUserGroupIds || null;
     const expandContentWidth = this.crowi.configManager.getConfig('crowi', 'customize:isContainerFluid');
 
     // sanitize path
@@ -3759,8 +3766,8 @@ class PageService {
     if (expandContentWidth != null) {
       page.expandContentWidth = expandContentWidth;
     }
-    await this.validateAppliedScope(user, grant, grantUserGroupId);
-    page.applyScope(user, grant, grantUserGroupId);
+    await this.validateAppliedScope(user, grant, grantUserGroupIds);
+    page.applyScope(user, grant, grantUserGroupIds);
 
     let savedPage = await page.save();
     const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
@@ -3809,7 +3816,7 @@ class PageService {
     path = this.crowi.xss.process(path); // sanitize path
 
     const {
-      format = 'markdown', grantUserGroupId, grantedUsers,
+      format = 'markdown', grantUserGroupIds, grantedUsers,
     } = options;
     const grant = isTopPage(path) ? Page.GRANT_PUBLIC : options.grant;
 
@@ -3819,7 +3826,7 @@ class PageService {
     const grantData = {
       grant,
       grantedUserIds: isGrantOwner ? grantedUsers : undefined,
-      grantUserGroupId,
+      grantUserGroupIds,
     };
 
     // Validate
@@ -3839,7 +3846,7 @@ class PageService {
     this.setFieldExceptForGrantRevisionParent(page, path);
 
     // Apply scope
-    page.applyScope({ _id: grantedUsers?.[0] }, grant, grantUserGroupId);
+    page.applyScope({ _id: grantedUsers?.[0] }, grant, grantUserGroupIds);
 
     // Set parent
     if (isTopPage(path) || isGrantRestricted) { // set parent to null when GRANT_RESTRICTED
@@ -3879,12 +3886,12 @@ class PageService {
    * @param {UserDocument} user
    * @param options
    */
-  async updateGrant(page, user, grantData: {grant: PageGrant, grantedGroup: ObjectIdLike}): Promise<PageDocument> {
-    const { grant, grantedGroup } = grantData;
+  async updateGrant(page, user, grantData: {grant: PageGrant, grantedGroups: IGrantedGroup[]}): Promise<PageDocument> {
+    const { grant, grantedGroups } = grantData;
 
     const options = {
       grant,
-      grantUserGroupId: grantedGroup,
+      grantUserGroupIds: grantedGroups,
       isSyncRevisionToHackmd: false,
     };
 
@@ -3954,7 +3961,7 @@ class PageService {
     const newPageData = pageData;
 
     const grant = options.grant ?? clonedPageData.grant; // use the previous data if absence
-    const grantUserGroupId: undefined | ObjectIdLike = options.grantUserGroupId ?? clonedPageData.grantedGroup?._id.toString();
+    const grantUserGroupIds = options.grantUserGroupIds ?? clonedPageData.grantedGroups;
 
     const grantedUserIds = clonedPageData.grantedUserIds || [user._id];
     const shouldBeOnTree = grant !== PageGrant.GRANT_RESTRICTED;
@@ -3967,7 +3974,7 @@ class PageService {
       try {
         const shouldCheckDescendants = !options.overwriteScopesOfDescendants;
         // eslint-disable-next-line max-len
-        isGrantNormalized = await pageGrantService.isGrantNormalized(user, clonedPageData.path, grant, grantedUserIds, grantUserGroupId, shouldCheckDescendants);
+        isGrantNormalized = await pageGrantService.isGrantNormalized(user, clonedPageData.path, grant, grantedUserIds, grantUserGroupIds, shouldCheckDescendants);
       }
       catch (err) {
         logger.error(`Failed to validate grant of page at "${clonedPageData.path}" of grant ${grant}:`, err);
@@ -3978,7 +3985,7 @@ class PageService {
       }
 
       if (options.overwriteScopesOfDescendants) {
-        const updateGrantInfo = await pageGrantService.generateUpdateGrantInfoToOverwriteDescendants(user, grant, options.grantUserGroupId);
+        const updateGrantInfo = await pageGrantService.generateUpdateGrantInfoToOverwriteDescendants(user, grant, options.grantUserGroupIds);
         const canOverwriteDescendants = await pageGrantService.canOverwriteDescendants(clonedPageData.path, user, updateGrantInfo);
 
         if (!canOverwriteDescendants) {
@@ -4005,7 +4012,7 @@ class PageService {
       newPageData.descendantCount = 0;
     }
 
-    newPageData.applyScope(user, grant, grantUserGroupId);
+    newPageData.applyScope(user, grant, grantUserGroupIds);
 
     // update existing page
     let savedPage = await newPageData.save();
@@ -4068,16 +4075,16 @@ class PageService {
   }
 
 
-  async updatePageV4(pageData, body, previousBody, user, options: any = {}): Promise<PageDocument> {
+  async updatePageV4(pageData, body, previousBody, user, options: IOptionsForUpdate = {}): Promise<PageDocument> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Revision = mongoose.model('Revision') as any; // TODO: TypeScriptize model
 
     const grant = options.grant || pageData.grant; // use the previous data if absence
-    const grantUserGroupId = options.grantUserGroupId || pageData.grantUserGroupId; // use the previous data if absence
+    const grantUserGroupIds = options.grantUserGroupIds || pageData.grantUserGroupIds; // use the previous data if absence
     const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
 
-    await this.validateAppliedScope(user, grant, grantUserGroupId);
-    pageData.applyScope(user, grant, grantUserGroupId);
+    await this.validateAppliedScope(user, grant, grantUserGroupIds);
+    pageData.applyScope(user, grant, grantUserGroupIds);
 
     // update existing page
     let savedPage = await pageData.save();

+ 1 - 1
apps/app/src/server/service/passport.ts

@@ -984,4 +984,4 @@ class PassportService implements S2sMessageHandlable {
 
 }
 
-module.exports = PassportService;
+export default PassportService;

+ 12 - 9
apps/app/src/server/service/search-delegator/elasticsearch.ts

@@ -9,6 +9,7 @@ import { SearchDelegatorName } from '~/interfaces/named-query';
 import {
   ISearchResult, ISearchResultData, SORT_AXIS, SORT_ORDER,
 } from '~/interfaces/search';
+import { SocketEventName } from '~/interfaces/websocket';
 import loggerFactory from '~/utils/logger';
 
 import {
@@ -301,7 +302,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       logger.error('error.meta.body', error?.meta?.body);
 
       const socket = this.socketIoService.getAdminSocket();
-      socket.emit('rebuildingFailed', { error: error.message });
+      socket.emit(SocketEventName.RebuildingFailed, { error: error.message });
 
       throw error;
     }
@@ -366,16 +367,18 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       });
     }
 
-    let grantedGroupId = null;
-    if (page.grantedGroup != null) {
-      const groupId = (page.grantedGroup._id == null) ? page.grantedGroup : page.grantedGroup._id;
-      grantedGroupId = groupId.toString();
+    let grantedGroupIds = null;
+    if (page.grantedGroups != null) {
+      grantedGroupIds = page.grantedGroups.map((group) => {
+        const groupId = (group.item._id == null) ? group.item : group.item._id;
+        return groupId.toString();
+      });
     }
 
     return {
       grant: page.grant,
       granted_users: grantedUserIds,
-      granted_group: grantedGroupId,
+      granted_groups: grantedGroupIds,
     };
   }
 
@@ -580,7 +583,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
           logger.info(`Adding pages progressing: (count=${count}, errors=${bulkResponse.errors}, took=${bulkResponse.took}ms)`);
 
           if (shouldEmitProgress) {
-            socket?.emit('addPageProgress', { totalCount, count, skipped });
+            socket?.emit(SocketEventName.AddPageProgress, { totalCount, count, skipped });
           }
         }
         catch (err) {
@@ -604,7 +607,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
         logger.info(`Adding pages has completed: (totalCount=${totalCount}, skipped=${skipped})`);
 
         if (shouldEmitProgress) {
-          socket?.emit('finishAddPage', { totalCount, count, skipped });
+          socket?.emit(SocketEventName.FinishAddPage, { totalCount, count, skipped });
         }
         callback();
       },
@@ -882,7 +885,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
           bool: {
             must: [
               { term: { grant: GRANT_USER_GROUP } },
-              { terms: { granted_group: userGroupIds } },
+              { terms: { granted_groups: userGroupIds } },
             ],
           },
         },

+ 3 - 2
apps/app/src/server/service/search.ts

@@ -14,6 +14,7 @@ import NamedQuery from '../models/named-query';
 import { PageModel } from '../models/page';
 import { serializeUserSecurely } from '../models/serializers/user-serializer';
 import { SearchError } from '../models/vo/search-error';
+import { hasIntersection } from '../util/compare-objectId';
 
 import ElasticsearchDelegator from './search-delegator/elasticsearch';
 import PrivateLegacyPagesDelegator from './search-delegator/private-legacy-pages';
@@ -491,7 +492,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
 
     const testGrant = pageData.grant;
     const testGrantedUser = pageData.grantedUsers?.[0];
-    const testGrantedGroup = pageData.grantedGroup;
+    const testGrantedGroups = pageData.grantedGroups;
 
     if (testGrant === Page.GRANT_RESTRICTED) {
       return false;
@@ -506,7 +507,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
     if (testGrant === Page.GRANT_USER_GROUP) {
       if (userGroups == null) return false;
 
-      return userGroups.map(id => id.toString()).includes(testGrantedGroup.toString());
+      return hasIntersection(userGroups.map(id => id.toString()), testGrantedGroups);
     }
 
     return true;

+ 19 - 14
apps/app/src/server/service/user-group.ts

@@ -1,15 +1,16 @@
-import type { IUser } from '@growi/core';
-import mongoose from 'mongoose';
+import type { IUser, IGrantedGroup } from '@growi/core';
+import { DeleteResult } from 'mongodb';
+import { Model } from 'mongoose';
 
 import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
-import UserGroup from '~/server/models/user-group';
-import { excludeTestIdsFromTargetIds, isIncludesObjectId } from '~/server/util/compare-objectId';
+import UserGroup, { UserGroupDocument, UserGroupModel } from '~/server/models/user-group';
+import { excludeTestIdsFromTargetIds, includesObjectIds } from '~/server/util/compare-objectId';
 import loggerFactory from '~/utils/logger';
 
-const logger = loggerFactory('growi:service:UserGroupService'); // eslint-disable-line no-unused-vars
+import UserGroupRelation, { UserGroupRelationDocument, UserGroupRelationModel } from '../models/user-group-relation';
 
 
-const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
+const logger = loggerFactory('growi:service:UserGroupService'); // eslint-disable-line no-unused-vars
 
 /**
  * the service class of UserGroupService
@@ -78,7 +79,7 @@ class UserGroupService {
 
     // throw if parent was in self and its descendants
     const descendantsWithTarget = await UserGroup.findGroupsWithDescendantsRecursively([userGroup]);
-    if (isIncludesObjectId(descendantsWithTarget.map(d => d._id), parent._id)) {
+    if (includesObjectIds(descendantsWithTarget.map(d => d._id), [parent._id])) {
       throw Error('It is not allowed to choose parent from descendant groups.');
     }
 
@@ -113,20 +114,24 @@ class UserGroupService {
     return userGroup.save();
   }
 
-  async removeCompletelyByRootGroupId(deleteRootGroupId, action, transferToUserGroupId, user) {
-    const rootGroup = await UserGroup.findById(deleteRootGroupId);
+  async removeCompletelyByRootGroupId(
+      deleteRootGroupId, action, user, transferToUserGroup?: IGrantedGroup,
+      userGroupModel: Model<UserGroupDocument> & UserGroupModel = UserGroup,
+      userGroupRelationModel: Model<UserGroupRelationDocument> & UserGroupRelationModel = UserGroupRelation,
+  ): Promise<DeleteResult> {
+    const rootGroup = await userGroupModel.findById(deleteRootGroupId);
     if (rootGroup == null) {
       throw new Error(`UserGroup data does not exist. id: ${deleteRootGroupId}`);
     }
 
-    const groupsToDelete = await UserGroup.findGroupsWithDescendantsRecursively([rootGroup]);
+    const groupsToDelete = await userGroupModel.findGroupsWithDescendantsRecursively([rootGroup]);
 
     // 1. update page & remove all groups
-    await this.crowi.pageService.handlePrivatePagesForGroupsToDelete(groupsToDelete, action, transferToUserGroupId, user);
+    await this.crowi.pageService.handlePrivatePagesForGroupsToDelete(groupsToDelete, action, transferToUserGroup, user);
     // 2. remove all groups
-    const deletedGroups = await UserGroup.deleteMany({ _id: { $in: groupsToDelete.map(g => g._id) } });
+    const deletedGroups = await userGroupModel.deleteMany({ _id: { $in: groupsToDelete.map(g => g._id) } });
     // 3. remove all relations
-    await UserGroupRelation.removeAllByUserGroups(groupsToDelete);
+    await userGroupRelationModel.removeAllByUserGroups(groupsToDelete);
 
     return deletedGroups;
   }
@@ -149,4 +154,4 @@ class UserGroupService {
 
 }
 
-module.exports = UserGroupService;
+export default UserGroupService;

+ 51 - 0
apps/app/src/server/util/compare-objectId.spec.ts

@@ -0,0 +1,51 @@
+import { Types } from 'mongoose';
+
+import { hasIntersection, includesObjectIds } from './compare-objectId';
+
+describe('Objectid comparison utils', () => {
+  const id1 = new Types.ObjectId();
+  const id2 = new Types.ObjectId();
+  const id3 = new Types.ObjectId();
+  const id4 = new Types.ObjectId();
+
+  describe('includesObjectIds', () => {
+    describe('When subset of array given', () => {
+      const arr = [id1, id2, id3, id4];
+      const subset = [id1, id4];
+
+      it('returns true', () => {
+        expect(includesObjectIds(arr, subset)).toBe(true);
+      });
+    });
+
+    describe('When set that intersects with array given', () => {
+      const arr = [id1, id2, id3];
+      const subset = [id1, id4];
+
+      it('returns false', () => {
+        expect(includesObjectIds(arr, subset)).toBe(false);
+      });
+    });
+  });
+
+  describe('hasIntersection', () => {
+    describe('When arrays have intersection', () => {
+      const arr1 = [id1, id2, id3, id4];
+      const arr2 = [id1, id4];
+
+      it('returns true', () => {
+        expect(hasIntersection(arr1, arr2)).toBe(true);
+      });
+    });
+
+    describe('When arrays don\'t have intersection', () => {
+      const arr1 = [id1, id2];
+      const arr2 = [id3, id4];
+
+      it('returns false', () => {
+        expect(hasIntersection(arr1, arr2)).toBe(false);
+      });
+    });
+  });
+
+});

+ 22 - 3
apps/app/src/server/util/compare-objectId.ts

@@ -5,11 +5,30 @@ import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 type IObjectId = mongoose.Types.ObjectId;
 const ObjectId = mongoose.Types.ObjectId;
 
-export const isIncludesObjectId = (arr: ObjectIdLike[], id: ObjectIdLike): boolean => {
+/**
+ * Check if array contains all specified ObjectIds
+ * @param arr array that potentially contains potentialSubset
+ * @param potentialSubset array that is potentially a subset of arr
+ * @returns Whether or not arr includes all elements of potentialSubset
+ */
+export const includesObjectIds = (arr: ObjectIdLike[], potentialSubset: ObjectIdLike[]): boolean => {
   const _arr = arr.map(i => i.toString());
-  const _id = id.toString();
+  const _potentialSubset = potentialSubset.map(i => i.toString());
+
+  return _potentialSubset.every(id => _arr.includes(id));
+};
+
+/**
+ * Check if 2 arrays have an intersection
+ * @param arr1 an array with ObjectIds
+ * @param arr2 another array with ObjectIds
+ * @returns Whether or not arr1 and arr2 have an intersection
+ */
+export const hasIntersection = (arr1: ObjectIdLike[], arr2: ObjectIdLike[]): boolean => {
+  const _arr1 = arr1.map(i => i.toString());
+  const _arr2 = arr2.map(i => i.toString());
 
-  return _arr.includes(_id);
+  return _arr1.some(item => _arr2.includes(item));
 };
 
 /**

+ 27 - 0
apps/app/src/server/util/granted-group.ts

@@ -0,0 +1,27 @@
+import { type IGrantedGroup, GroupType } from '@growi/core';
+
+import { ObjectIdLike } from '../interfaces/mongoose-utils';
+
+export const divideByType = (grantedGroups: IGrantedGroup[] | null): {
+  grantedUserGroups: ObjectIdLike[];
+  grantedExternalUserGroups: ObjectIdLike[];
+} => {
+  const grantedUserGroups: ObjectIdLike[] = [];
+  const grantedExternalUserGroups: ObjectIdLike[] = [];
+
+  if (grantedGroups == null) {
+    return { grantedUserGroups, grantedExternalUserGroups };
+  }
+
+  grantedGroups.forEach((group) => {
+    const id = typeof group.item === 'string' ? group.item : group.item._id;
+    if (group.type === GroupType.userGroup) {
+      grantedUserGroups.push(id);
+    }
+    else {
+      grantedExternalUserGroups.push(id);
+    }
+  });
+
+  return { grantedUserGroups, grantedExternalUserGroups };
+};

+ 34 - 20
apps/app/src/stores/user-group.tsx

@@ -1,9 +1,11 @@
-import type { IPageHasId, IUserGroupHasId, IUserGroupRelationHasId } from '@growi/core';
+import type {
+  IPageHasId, IUserGroupHasId, IUserGroupRelationHasId,
+} from '@growi/core';
+import { type SWRResponseWithUtils, withUtils } from '@growi/core/dist/swr';
 import useSWR, { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
-import { apiGet } from '~/client/util/apiv1-client';
-import { apiv3Get } from '~/client/util/apiv3-client';
+import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 import {
   IUserGroupRelationHasIdPopulatedUser,
   UserGroupResult, UserGroupListResult, ChildUserGroupListResult, UserGroupRelationListResult, UserGroupRelationsResult,
@@ -11,27 +13,24 @@ import {
 } from '~/interfaces/user-group-response';
 
 
-type MyUserGroupRelationsResult = {
-  userGroupRelations: IUserGroupRelationHasId[],
-}
-
-export const useSWRxMyUserGroupRelations = (shouldFetch: boolean): SWRResponse<IUserGroupRelationHasId[], Error> => {
+export const useSWRxMyUserGroups = (shouldFetch: boolean): SWRResponse<IUserGroupHasId[], Error> => {
   return useSWR(
-    shouldFetch ? '/me/user-group-relations' : null,
-    endpoint => apiGet(endpoint).then(result => (result as MyUserGroupRelationsResult).userGroupRelations),
+    shouldFetch ? '/me/user-groups' : null,
+    endpoint => apiv3Get<UserGroupListResult>(endpoint).then(result => result.data.userGroups),
   );
 };
 
-export const useSWRxUserGroup = (groupId: string | undefined): SWRResponse<IUserGroupHasId, Error> => {
+export const useSWRxUserGroup = (groupId: string | null): SWRResponse<IUserGroupHasId, Error> => {
   return useSWRImmutable(
     groupId != null ? `/user-groups/${groupId}` : null,
     endpoint => apiv3Get<UserGroupResult>(endpoint).then(result => result.data.userGroup),
   );
 };
 
-export const useSWRxUserGroupList = (initialData?: IUserGroupHasId[]): SWRResponse<IUserGroupHasId[], Error> => {
+export const useSWRxUserGroupList = (initialData?: IUserGroupHasId[], isExternalGroup = false): SWRResponse<IUserGroupHasId[], Error> => {
+  const url = isExternalGroup ? '/external-user-groups' : '/user-groups';
   return useSWRImmutable(
-    '/user-groups',
+    url,
     endpoint => apiv3Get<UserGroupListResult>(endpoint, { pagination: false }).then(result => result.data.userGroups),
     {
       fallbackData: initialData,
@@ -39,19 +38,34 @@ export const useSWRxUserGroupList = (initialData?: IUserGroupHasId[]): SWRRespon
   );
 };
 
+type ChildUserGroupListUtils = {
+  updateChild(childGroupData: IUserGroupHasId): Promise<void>, // update one child and refresh list
+}
 export const useSWRxChildUserGroupList = (
     parentIds?: string[], includeGrandChildren?: boolean,
-): SWRResponse<ChildUserGroupListResult, Error> => {
+): SWRResponseWithUtils<ChildUserGroupListUtils, ChildUserGroupListResult, Error> => {
   const shouldFetch = parentIds != null && parentIds.length > 0;
-  return useSWRImmutable(
+
+  const swrResponse = useSWRImmutable(
     shouldFetch ? ['/user-groups/children', parentIds, includeGrandChildren] : null,
     ([endpoint, parentIds, includeGrandChildren]) => apiv3Get<ChildUserGroupListResult>(
       endpoint, { parentIds, includeGrandChildren },
     ).then((result => result.data)),
   );
+
+  const updateChild = async(childGroupData: IUserGroupHasId) => {
+    await apiv3Put(`/user-groups/${childGroupData._id}`, {
+      name: childGroupData.name,
+      description: childGroupData.description,
+      parentId: childGroupData.parent,
+    });
+    swrResponse.mutate();
+  };
+
+  return withUtils(swrResponse, { updateChild });
 };
 
-export const useSWRxUserGroupRelations = (groupId: string): SWRResponse<IUserGroupRelationHasIdPopulatedUser[], Error> => {
+export const useSWRxUserGroupRelations = (groupId: string | null): SWRResponse<IUserGroupRelationHasIdPopulatedUser[], Error> => {
   return useSWRImmutable(
     groupId != null ? `/user-groups/${groupId}/user-group-relations` : null,
     endpoint => apiv3Get<UserGroupRelationsResult>(endpoint).then(result => result.data.userGroupRelations),
@@ -59,7 +73,7 @@ export const useSWRxUserGroupRelations = (groupId: string): SWRResponse<IUserGro
 };
 
 export const useSWRxUserGroupRelationList = (
-    groupIds: string[] | undefined, childGroupIds?: string[], initialData?: IUserGroupRelationHasId[],
+    groupIds: string[] | null, childGroupIds?: string[], initialData?: IUserGroupRelationHasId[],
 ): SWRResponse<IUserGroupRelationHasId[], Error> => {
   return useSWRImmutable(
     groupIds != null ? ['/user-group-relations', groupIds, childGroupIds] : null,
@@ -79,21 +93,21 @@ export const useSWRxUserGroupPages = (groupId: string | undefined, limit: number
   );
 };
 
-export const useSWRxSelectableParentUserGroups = (groupId: string | undefined): SWRResponse<IUserGroupHasId[], Error> => {
+export const useSWRxSelectableParentUserGroups = (groupId: string | null): SWRResponse<IUserGroupHasId[], Error> => {
   return useSWRImmutable(
     groupId != null ? ['/user-groups/selectable-parent-groups', groupId] : null,
     ([endpoint, groupId]) => apiv3Get<SelectableParentUserGroupsResult>(endpoint, { groupId }).then(result => result.data.selectableParentGroups),
   );
 };
 
-export const useSWRxSelectableChildUserGroups = (groupId: string | undefined): SWRResponse<IUserGroupHasId[], Error> => {
+export const useSWRxSelectableChildUserGroups = (groupId: string | null): SWRResponse<IUserGroupHasId[], Error> => {
   return useSWRImmutable(
     groupId != null ? ['/user-groups/selectable-child-groups', groupId] : null,
     ([endpoint, groupId]) => apiv3Get<SelectableUserChildGroupsResult>(endpoint, { groupId }).then(result => result.data.selectableChildGroups),
   );
 };
 
-export const useSWRxAncestorUserGroups = (groupId: string | undefined): SWRResponse<IUserGroupHasId[], Error> => {
+export const useSWRxAncestorUserGroups = (groupId: string | null): SWRResponse<IUserGroupHasId[], Error> => {
   return useSWRImmutable(
     groupId != null ? ['/user-groups/ancestors', groupId] : null,
     ([endpoint, groupId]) => apiv3Get<AncestorUserGroupsResult>(endpoint, { groupId }).then(result => result.data.ancestorUserGroups),

+ 51 - 0
apps/app/src/utils/promise.spec.ts

@@ -0,0 +1,51 @@
+import { batchProcessPromiseAll } from './promise';
+
+describe('batchProcessPromiseAll', () => {
+  it('processes items in batch', async() => {
+    const batch1 = [1, 2, 3, 4, 5];
+    const batch2 = [6, 7, 8, 9, 10];
+    const batch3 = [11, 12];
+    const all = [...batch1, ...batch2, ...batch3];
+
+    const actualProcessedBatches: number[][] = [];
+    const result = await batchProcessPromiseAll(all, 5, async(num, i, arr) => {
+      if (arr != null && i === 0) {
+        actualProcessedBatches.push(arr);
+      }
+      return num * 10;
+    });
+
+    const expected = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120];
+
+    expect(result).toStrictEqual(expected);
+    expect(actualProcessedBatches).toStrictEqual([
+      batch1,
+      batch2,
+      batch3,
+    ]);
+  });
+
+  describe('error handling', () => {
+    const all = [1, 2, 3, 4, 5, 6, 7, 8, '9', 10];
+
+    const multiplyBy10 = async(num) => {
+      if (typeof num !== 'number') {
+        throw new Error('Is not number');
+      }
+      return num * 10;
+    };
+
+    describe('when throwIfRejected is true', () => {
+      it('throws error when there is a Promise rejection', async() => {
+        await expect(batchProcessPromiseAll(all, 5, multiplyBy10)).rejects.toThrow('Is not number');
+      });
+    });
+
+    describe('when throwIfRejected is false', () => {
+      it('doesn\'t throw error when there is a Promise rejection', async() => {
+        const expected = [10, 20, 30, 40, 50, 60, 70, 80, 100];
+        await expect(batchProcessPromiseAll(all, 5, multiplyBy10, false)).resolves.toStrictEqual(expected);
+      });
+    });
+  });
+});

+ 35 - 0
apps/app/src/utils/promise.ts

@@ -0,0 +1,35 @@
+/**
+ * Divide arrays into batches, and apply a given function to each batch by using Promise.all
+ * Use when memory consumption can be too large by using simple Promise.all
+ * @param items array to process
+ * @param limit batch size
+ * @param fn function to apply on each item
+ * @param throwIfRejected whether or not to throw Error when there is a rejected Promise
+ * @returns result of fn applied to each item
+ */
+export const batchProcessPromiseAll = async<I, O>(
+  items: Array<I>,
+  limit: number,
+  fn: (item: I, index?: number, array?: Array<I>) => Promise<O>,
+  throwIfRejected = true,
+): Promise<O[]> => {
+  const results: O[] = [];
+
+  for (let start = 0; start < items.length; start += limit) {
+    const end = Math.min(start + limit, items.length);
+
+    // eslint-disable-next-line no-await-in-loop
+    const slicedResults = await Promise.allSettled(items.slice(start, end).map(fn));
+
+    slicedResults.forEach((result) => {
+      if (result.status === 'fulfilled') {
+        results.push(result.value);
+      }
+      else if (throwIfRejected && result.reason instanceof Error) {
+        throw result.reason;
+      }
+    });
+  }
+
+  return results;
+};

+ 1 - 1
apps/app/test/integration/models/page.test.js

@@ -101,7 +101,7 @@ describe('Page', () => {
         path: '/grant/groupacl',
         grant: Page.GRANT_USER_GROUP,
         grantedUsers: [],
-        grantedGroup: testGroup0,
+        grantedGroups: [{ item: testGroup0, type: 'UserGroup' }],
         creator: testUser1,
       },
       {

+ 337 - 62
apps/app/test/integration/models/v5.page.test.js

@@ -1,6 +1,11 @@
-import { PageGrant } from '@growi/core';
+import { PageGrant, GroupType } from '@growi/core';
 import mongoose from 'mongoose';
 
+import { ExternalGroupProviderType } from '../../../src/features/external-user-group/interfaces/external-user-group';
+import ExternalUserGroup from '../../../src/features/external-user-group/server/models/external-user-group';
+import ExternalUserGroupRelation from '../../../src/features/external-user-group/server/models/external-user-group-relation';
+import UserGroup from '../../../src/server/models/user-group';
+import UserGroupRelation from '../../../src/server/models/user-group-relation';
 import { getInstance } from '../setup-crowi';
 
 describe('Page', () => {
@@ -16,8 +21,6 @@ describe('Page', () => {
   let Comment;
   let ShareLink;
   let PageRedirect;
-  let UserGroup;
-  let UserGroupRelation;
   let xssSpy;
 
   let rootPage;
@@ -29,6 +32,10 @@ describe('Page', () => {
   let userGroupIdPModelA;
   let userGroupIdPModelB;
   let userGroupIdPModelC;
+  let externalUserGroupIdPModelIsolate;
+  let externalUserGroupIdPModelA;
+  let externalUserGroupIdPModelB;
+  let externalUserGroupIdPModelC;
 
   // To test updatePage overwriting descendants (prefix `upod`)
   let upodUserA;
@@ -44,6 +51,11 @@ describe('Page', () => {
   const upodUserGroupIdB = new mongoose.Types.ObjectId();
   const upodUserGroupIdC = new mongoose.Types.ObjectId();
   const upodUserGroupIdAB = new mongoose.Types.ObjectId();
+  const upodExternalUserGroupIdA = new mongoose.Types.ObjectId();
+  const upodExternalUserGroupIdAIsolated = new mongoose.Types.ObjectId();
+  const upodExternalUserGroupIdB = new mongoose.Types.ObjectId();
+  const upodExternalUserGroupIdC = new mongoose.Types.ObjectId();
+  const upodExternalUserGroupIdAB = new mongoose.Types.ObjectId();
   const upodPageIdgAB1 = new mongoose.Types.ObjectId();
   const upodPageIdPublic2 = new mongoose.Types.ObjectId();
   const upodPageIdPublic3 = new mongoose.Types.ObjectId();
@@ -139,6 +151,76 @@ describe('Page', () => {
       },
     ]);
 
+    // Insert ExternalUserGroups with the same group structure as UserGroups
+    // Use to test
+    //   - ExternalUserGroup
+    //   - Case of multiple grantedGroups for Page
+    await ExternalUserGroup.insertMany([
+      {
+        _id: upodExternalUserGroupIdAB,
+        name: 'upodExternalGroupAB',
+        parent: null,
+        externalId: 'upodExternalGroupAB',
+        provider: ExternalGroupProviderType.ldap,
+      },
+      {
+        _id: upodExternalUserGroupIdA,
+        name: 'upodExternalGroupA',
+        parent: upodExternalUserGroupIdAB,
+        externalId: 'upodExternalGroupA',
+        provider: ExternalGroupProviderType.ldap,
+      },
+      {
+        _id: upodExternalUserGroupIdAIsolated,
+        name: 'upodExternalGroupAIsolated',
+        parent: null,
+        externalId: 'upodExternalGroupAIsolated',
+        provider: ExternalGroupProviderType.ldap,
+      },
+      {
+        _id: upodExternalUserGroupIdB,
+        name: 'upodExternalGroupB',
+        parent: upodExternalUserGroupIdAB,
+        externalId: 'upodExternalGroupB',
+        provider: ExternalGroupProviderType.ldap,
+      },
+      {
+        _id: upodExternalUserGroupIdC,
+        name: 'upodExternalGroupC',
+        parent: null,
+        externalId: 'upodExternalGroupC',
+        provider: ExternalGroupProviderType.ldap,
+      },
+    ]);
+
+    // ExternalUserGroupRelations
+    await ExternalUserGroupRelation.insertMany([
+      {
+        relatedGroup: upodExternalUserGroupIdAB,
+        relatedUser: upodUserA._id,
+      },
+      {
+        relatedGroup: upodExternalUserGroupIdAB,
+        relatedUser: upodUserB._id,
+      },
+      {
+        relatedGroup: upodExternalUserGroupIdA,
+        relatedUser: upodUserA._id,
+      },
+      {
+        relatedGroup: upodExternalUserGroupIdAIsolated,
+        relatedUser: upodUserA._id,
+      },
+      {
+        relatedGroup: upodExternalUserGroupIdB,
+        relatedUser: upodUserB._id,
+      },
+      {
+        relatedGroup: upodExternalUserGroupIdC,
+        relatedUser: upodUserC._id,
+      },
+    ]);
+
     // Pages
     await Page.insertMany([
       // case 1
@@ -149,7 +231,10 @@ describe('Page', () => {
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: null,
-        grantedGroup: upodUserGroupIdAB,
+        grantedGroups: [
+          { item: upodUserGroupIdAB, type: GroupType.userGroup },
+          { item: upodExternalUserGroupIdAB, type: GroupType.externalUserGroup },
+        ],
         parent: rootPage._id,
       },
       {
@@ -158,7 +243,10 @@ describe('Page', () => {
         creator: upodUserB,
         lastUpdateUser: upodUserB,
         grantedUsers: null,
-        grantedGroup: upodUserGroupIdB,
+        grantedGroups: [
+          { item: upodUserGroupIdB, type: GroupType.userGroup },
+          { item: upodExternalUserGroupIdB, type: GroupType.externalUserGroup },
+        ],
         parent: upodPageIdgAB1,
       },
       {
@@ -167,7 +255,7 @@ describe('Page', () => {
         creator: upodUserB,
         lastUpdateUser: upodUserB,
         grantedUsers: [upodUserB._id],
-        grantedGroup: null,
+        grantedGroups: null,
         parent: upodPageIdgAB1,
       },
       // case 2
@@ -178,7 +266,7 @@ describe('Page', () => {
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: null,
-        grantedGroup: null,
+        grantedGroups: null,
         parent: rootPage._id,
       },
       {
@@ -187,7 +275,10 @@ describe('Page', () => {
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: null,
-        grantedGroup: upodUserGroupIdA,
+        grantedGroups: [
+          { item: upodUserGroupIdA, type: GroupType.userGroup },
+          { item: upodExternalUserGroupIdA, type: GroupType.externalUserGroup },
+        ],
         parent: upodPageIdPublic2,
       },
       {
@@ -196,7 +287,10 @@ describe('Page', () => {
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: null,
-        grantedGroup: upodUserGroupIdAIsolated,
+        grantedGroups: [
+          { item: upodUserGroupIdAIsolated, type: GroupType.userGroup },
+          { item: upodExternalUserGroupIdAIsolated, type: GroupType.externalUserGroup },
+        ],
         parent: upodPageIdPublic2,
       },
       {
@@ -205,7 +299,7 @@ describe('Page', () => {
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: [upodUserA._id],
-        grantedGroup: null,
+        grantedGroups: null,
         parent: upodPageIdPublic2,
       },
       // case 3
@@ -216,7 +310,7 @@ describe('Page', () => {
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: null,
-        grantedGroup: null,
+        grantedGroups: null,
         parent: rootPage._id,
       },
       {
@@ -225,7 +319,10 @@ describe('Page', () => {
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: null,
-        grantedGroup: upodUserGroupIdAB,
+        grantedGroups: [
+          { item: upodUserGroupIdAB, type: GroupType.userGroup },
+          { item: upodExternalUserGroupIdAB, type: GroupType.externalUserGroup },
+        ],
         parent: upodPageIdPublic3,
       },
       {
@@ -234,7 +331,10 @@ describe('Page', () => {
         creator: upodUserB,
         lastUpdateUser: upodUserB,
         grantedUsers: null,
-        grantedGroup: upodUserGroupIdB,
+        grantedGroups: [
+          { item: upodUserGroupIdB, type: GroupType.userGroup },
+          { item: upodExternalUserGroupIdB, type: GroupType.externalUserGroup },
+        ],
         parent: upodPageIdPublic3,
       },
       {
@@ -243,7 +343,7 @@ describe('Page', () => {
         creator: upodUserB,
         lastUpdateUser: upodUserB,
         grantedUsers: [upodUserB._id],
-        grantedGroup: null,
+        grantedGroups: null,
         parent: upodPageIdPublic3,
       },
       // case 4
@@ -254,7 +354,7 @@ describe('Page', () => {
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: null,
-        grantedGroup: null,
+        grantedGroups: null,
         parent: rootPage._id,
       },
       {
@@ -263,7 +363,10 @@ describe('Page', () => {
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: null,
-        grantedGroup: upodUserGroupIdA,
+        grantedGroups: [
+          { item: upodUserGroupIdA, type: GroupType.userGroup },
+          { item: upodExternalUserGroupIdA, type: GroupType.externalUserGroup },
+        ],
         parent: upodPageIdPublic4,
       },
       {
@@ -272,7 +375,10 @@ describe('Page', () => {
         creator: upodUserC,
         lastUpdateUser: upodUserC,
         grantedUsers: null,
-        grantedGroup: upodUserGroupIdC,
+        grantedGroups: [
+          { item: upodUserGroupIdC, type: GroupType.userGroup },
+          { item: upodExternalUserGroupIdC, type: GroupType.externalUserGroup },
+        ],
         parent: upodPageIdPublic4,
       },
       // case 5
@@ -283,7 +389,7 @@ describe('Page', () => {
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: null,
-        grantedGroup: null,
+        grantedGroups: null,
         parent: rootPage._id,
       },
       {
@@ -292,7 +398,10 @@ describe('Page', () => {
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: null,
-        grantedGroup: upodUserGroupIdA,
+        grantedGroups: [
+          { item: upodUserGroupIdA, type: GroupType.userGroup },
+          { item: upodExternalUserGroupIdA, type: GroupType.externalUserGroup },
+        ],
         parent: upodPageIdPublic5,
       },
       {
@@ -301,7 +410,7 @@ describe('Page', () => {
         creator: upodUserC,
         lastUpdateUser: upodUserC,
         grantedUsers: [upodUserC._id],
-        grantedGroup: null,
+        grantedGroups: null,
         parent: upodPageIdPublic5,
       },
       // case 6
@@ -312,7 +421,7 @@ describe('Page', () => {
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: null,
-        grantedGroup: null,
+        grantedGroups: null,
         parent: rootPage._id,
       },
       {
@@ -321,12 +430,20 @@ describe('Page', () => {
         creator: upodUserC,
         lastUpdateUser: upodUserC,
         grantedUsers: [upodUserC._id],
-        grantedGroup: null,
+        grantedGroups: null,
         parent: upodPageIdPublic6,
       },
     ]);
   };
 
+  // normalize for result comparison
+  const normalizeGrantedGroups = (grantedGroups) => {
+    return grantedGroups.map((group) => {
+      const itemId = typeof group.item === 'string' ? group.item : group.item._id;
+      return { item: itemId, type: group.type };
+    });
+  };
+
   beforeAll(async() => {
     crowi = await getInstance();
     pageGrantService = crowi.pageGrantService;
@@ -343,8 +460,6 @@ describe('Page', () => {
     Comment = mongoose.model('Comment');
     ShareLink = mongoose.model('ShareLink');
     PageRedirect = mongoose.model('PageRedirect');
-    UserGroup = mongoose.model('UserGroup');
-    UserGroupRelation = mongoose.model('UserGroupRelation');
 
     dummyUser1 = await User.findOne({ username: 'v5DummyUser1' });
 
@@ -446,6 +561,86 @@ describe('Page', () => {
       },
     ]);
 
+    // Insert ExternalUserGroups with the same group structure as UserGroups
+    // Use to test
+    //   - ExternalUserGroup
+    //   - Case of multiple grantedGroups for Page
+    externalUserGroupIdPModelIsolate = new mongoose.Types.ObjectId();
+    externalUserGroupIdPModelA = new mongoose.Types.ObjectId();
+    externalUserGroupIdPModelB = new mongoose.Types.ObjectId();
+    externalUserGroupIdPModelC = new mongoose.Types.ObjectId();
+    await ExternalUserGroup.insertMany([
+      {
+        _id: externalUserGroupIdPModelIsolate,
+        name: 'pModel_externalGroupIsolate',
+        externalId: 'pModel_externalGroupIsolate',
+        provider: ExternalGroupProviderType.ldap,
+      },
+      {
+        _id: externalUserGroupIdPModelA,
+        name: 'pModel_externalGroupA',
+        externalId: 'pModel_externalGroupA',
+        provider: ExternalGroupProviderType.ldap,
+      },
+      {
+        _id: externalUserGroupIdPModelB,
+        name: 'pModel_externalGroupB',
+        parent: externalUserGroupIdPModelA,
+        externalId: 'pModel_externalGroupB',
+        provider: ExternalGroupProviderType.ldap,
+      },
+      {
+        _id: externalUserGroupIdPModelC,
+        name: 'pModel_externalGroupC',
+        parent: externalUserGroupIdPModelB,
+        externalId: 'pModel_externalGroupC',
+        provider: ExternalGroupProviderType.ldap,
+      },
+    ]);
+
+    await ExternalUserGroupRelation.insertMany([
+      {
+        relatedGroup: externalUserGroupIdPModelIsolate,
+        relatedUser: pModelUserId1,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: externalUserGroupIdPModelIsolate,
+        relatedUser: pModelUserId2,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: externalUserGroupIdPModelA,
+        relatedUser: pModelUserId1,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: externalUserGroupIdPModelA,
+        relatedUser: pModelUserId2,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: externalUserGroupIdPModelA,
+        relatedUser: pModelUserId3,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: externalUserGroupIdPModelB,
+        relatedUser: pModelUserId2,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: externalUserGroupIdPModelB,
+        relatedUser: pModelUserId3,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: externalUserGroupIdPModelC,
+        relatedUser: pModelUserId3,
+        createdAt: new Date(),
+      },
+    ]);
+
     /**
      * update
      * mup_ => model update
@@ -610,7 +805,10 @@ describe('Page', () => {
       {
         path: '/mup20',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: userGroupIdPModelA,
+        grantedGroups: [
+          { item: userGroupIdPModelA, type: GroupType.userGroup },
+          { item: externalUserGroupIdPModelA, type: GroupType.externalUserGroup },
+        ],
         creator: pModelUserId1,
         lastUpdateUser: pModelUserId1,
         isEmpty: false,
@@ -638,7 +836,10 @@ describe('Page', () => {
       {
         path: '/mup22/mup23',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: userGroupIdPModelA,
+        grantedGroups: [
+          { item: userGroupIdPModelA, type: GroupType.userGroup },
+          { item: externalUserGroupIdPModelA, type: GroupType.externalUserGroup },
+        ],
         creator: pModelUserId1,
         lastUpdateUser: pModelUserId1,
         isEmpty: false,
@@ -696,7 +897,10 @@ describe('Page', () => {
         _id: pageIdUpd16,
         path: '/mup29_A',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: userGroupIdPModelA,
+        grantedGroups: [
+          { item: userGroupIdPModelA, type: GroupType.userGroup },
+          { item: externalUserGroupIdPModelA, type: GroupType.externalUserGroup },
+        ],
         creator: pModelUserId1,
         lastUpdateUser: pModelUserId1,
         isEmpty: false,
@@ -717,7 +921,10 @@ describe('Page', () => {
         _id: pageIdUpd17,
         path: '/mup31_A',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: userGroupIdPModelA,
+        grantedGroups: [
+          { item: userGroupIdPModelA, type: GroupType.userGroup },
+          { item: externalUserGroupIdPModelA, type: GroupType.externalUserGroup },
+        ],
         creator: pModelUserId1,
         lastUpdateUser: pModelUserId1,
         isEmpty: false,
@@ -738,7 +945,10 @@ describe('Page', () => {
         _id: pageIdUpd18,
         path: '/mup33_C',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: userGroupIdPModelC,
+        grantedGroups: [
+          { item: userGroupIdPModelC, type: GroupType.userGroup },
+          { item: externalUserGroupIdPModelC, type: GroupType.externalUserGroup },
+        ],
         creator: pModelUserId3,
         lastUpdateUser: pModelUserId3,
         isEmpty: false,
@@ -804,7 +1014,7 @@ describe('Page', () => {
         expect(page1).toBeTruthy();
         expect(page2).toBeTruthy();
 
-        const options = { grant: Page.GRANT_RESTRICTED, grantUserGroupId: null };
+        const options = { grant: Page.GRANT_RESTRICTED, grantUserGroupIds: null };
         await updatePage(page2, 'newRevisionBody', 'oldRevisionBody', dummyUser1, options);
 
         const _pageT = await Page.findOne({ path: pathT });
@@ -954,7 +1164,7 @@ describe('Page', () => {
       });
       test('successfully change to GRANT_OWNER from GRANT_USER_GROUP', async() => {
         const path = '/mup20';
-        const _page = await Page.findOne({ path, grant: Page.GRANT_USER_GROUP, grantedGroup: userGroupIdPModelA });
+        const _page = await Page.findOne({ path, grant: Page.GRANT_USER_GROUP, grantedGroups: { $elemMatch: { item: userGroupIdPModelA } } });
         expect(_page).toBeTruthy();
 
         await updatePage(_page, 'newRevisionBody', 'oldRevisionBody', pModelUser1, { grant: Page.GRANT_OWNER });
@@ -962,7 +1172,7 @@ describe('Page', () => {
         const page = await Page.findOne({ path });
         expect(page.grant).toBe(Page.GRANT_OWNER);
         expect(page.grantedUsers).toStrictEqual([pModelUser1._id]);
-        expect(page.grantedGroup).toBeNull();
+        expect(page.grantedGroups.length).toBe(0);
       });
       test('successfully change to GRANT_OWNER from GRANT_RESTRICTED', async() => {
         const path = '/mup21';
@@ -979,7 +1189,7 @@ describe('Page', () => {
         const path1 = '/mup22';
         const path2 = '/mup22/mup23';
         const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
-        const _page2 = await Page.findOne({ path: path2, grant: Page.GRANT_USER_GROUP, grantedGroup: userGroupIdPModelA });
+        const _page2 = await Page.findOne({ path: path2, grant: Page.GRANT_USER_GROUP, grantedGroups: { $elemMatch: { item: userGroupIdPModelA } } });
         expect(_page1).toBeTruthy();
         expect(_page2).toBeTruthy();
 
@@ -1004,7 +1214,15 @@ describe('Page', () => {
           expect(_page1).toBeTruthy();
           expect(_page2).toBeTruthy();
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelA };
+          const newGrantedGroups = [
+            { item: userGroupIdPModelA, type: GroupType.userGroup },
+            { item: externalUserGroupIdPModelA, type: GroupType.externalUserGroup },
+          ];
+
+          const options = {
+            grant: Page.GRANT_USER_GROUP,
+            grantUserGroupIds: newGrantedGroups,
+          };
           const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser1, options); // from GRANT_PUBLIC to GRANT_USER_GROUP(userGroupIdPModelA)
 
           const page1 = await Page.findById(_page1._id);
@@ -1016,7 +1234,7 @@ describe('Page', () => {
 
           // check page2 grant and group
           expect(page2.grant).toBe(Page.GRANT_USER_GROUP);
-          expect(page2.grantedGroup._id).toStrictEqual(userGroupIdPModelA);
+          expect(normalizeGrantedGroups(page2.grantedGroups)).toStrictEqual(newGrantedGroups);
         });
 
         test('successfully change to GRANT_USER_GROUP from GRANT_RESTRICTED if parent page is GRANT_PUBLIC', async() => {
@@ -1026,7 +1244,15 @@ describe('Page', () => {
           const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_RESTRICTED });
           expect(_page1).toBeTruthy();
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelA };
+          const newGrantedGroups = [
+            { item: userGroupIdPModelA, type: GroupType.userGroup },
+            { item: externalUserGroupIdPModelA, type: GroupType.externalUserGroup },
+          ];
+
+          const options = {
+            grant: Page.GRANT_USER_GROUP,
+            grantUserGroupIds: newGrantedGroups,
+          };
           const updatedPage = await updatePage(_page1, 'new', 'old', pModelUser1, options); // from GRANT_RESTRICTED to GRANT_USER_GROUP(userGroupIdPModelA)
 
           const page1 = await Page.findById(_page1._id);
@@ -1036,7 +1262,7 @@ describe('Page', () => {
 
           // updated page
           expect(page1.grant).toBe(Page.GRANT_USER_GROUP);
-          expect(page1.grantedGroup._id).toStrictEqual(userGroupIdPModelA);
+          expect(normalizeGrantedGroups(page1.grantedGroups)).toStrictEqual(newGrantedGroups);
 
           // parent's grant check
           const parent = await Page.findById(page1.parent);
@@ -1056,7 +1282,15 @@ describe('Page', () => {
           expect(_page1).toBeTruthy();
           expect(_page2).toBeTruthy();
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelA };
+          const newGrantedGroups = [
+            { item: userGroupIdPModelA, type: GroupType.userGroup },
+            { item: externalUserGroupIdPModelA, type: GroupType.externalUserGroup },
+          ];
+
+          const options = {
+            grant: Page.GRANT_USER_GROUP,
+            grantUserGroupIds: newGrantedGroups,
+          };
           const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser1, options); // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelA)
 
           const page1 = await Page.findById(_page1._id);
@@ -1068,7 +1302,7 @@ describe('Page', () => {
 
           // grant check
           expect(page2.grant).toBe(Page.GRANT_USER_GROUP);
-          expect(page2.grantedGroup._id).toStrictEqual(userGroupIdPModelA);
+          expect(normalizeGrantedGroups(page2.grantedGroups)).toStrictEqual(newGrantedGroups);
           expect(page2.grantedUsers.length).toBe(0);
         });
       });
@@ -1078,17 +1312,23 @@ describe('Page', () => {
           const _path1 = '/mup29_A';
           const _path2 = '/mup29_A/mup30_owner';
           // page
-          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: userGroupIdPModelA }); // out of update scope
+          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroups: { $elemMatch: { item: userGroupIdPModelA } } }); // out of update scope
           const _page2 = await Page.findOne({ // update target
             path: _path2, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser1], parent: _page1._id,
           });
           expect(_page1).toBeTruthy();
           expect(_page2).toBeTruthy();
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelB };
-
           // First round
           // Group relation(parent -> child): userGroupIdPModelA -> userGroupIdPModelB -> userGroupIdPModelC
+          const newGrantedGroups = [
+            { item: userGroupIdPModelB, type: GroupType.userGroup },
+            { item: externalUserGroupIdPModelB, type: GroupType.externalUserGroup },
+          ];
+          const options = {
+            grant: Page.GRANT_USER_GROUP,
+            grantUserGroupIds: newGrantedGroups,
+          };
           const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser3, options); // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelB)
 
           const page1 = await Page.findById(_page1._id);
@@ -1099,24 +1339,32 @@ describe('Page', () => {
           expect(updatedPage._id).toStrictEqual(page2._id);
 
           expect(page2.grant).toBe(Page.GRANT_USER_GROUP);
-          expect(page2.grantedGroup._id).toStrictEqual(userGroupIdPModelB);
+          expect(normalizeGrantedGroups(page2.grantedGroups)).toStrictEqual(newGrantedGroups);
           expect(page2.grantedUsers.length).toBe(0);
 
           // Second round
           // Update group to groupC which is a grandchild from pageA's point of view
-          const secondRoundOptions = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelC }; // from GRANT_USER_GROUP(userGroupIdPModelB) to GRANT_USER_GROUP(userGroupIdPModelC)
+          const secondRoundNewGrantedGroups = [
+            { item: userGroupIdPModelC, type: GroupType.userGroup },
+            { item: externalUserGroupIdPModelC, type: GroupType.externalUserGroup },
+          ];
+          const secondRoundOptions = { grant: Page.GRANT_USER_GROUP, grantUserGroupIds: secondRoundNewGrantedGroups }; // from GRANT_USER_GROUP(userGroupIdPModelB) to GRANT_USER_GROUP(userGroupIdPModelC)
+          // undo grantedGroups populate to prevent Page.hydrate error
+          _page2.grantedGroups.forEach((group) => {
+            group.item = group.item._id;
+          });
           const secondRoundUpdatedPage = await updatePage(_page2, 'new', 'new', pModelUser3, secondRoundOptions);
 
           expect(secondRoundUpdatedPage).toBeTruthy();
           expect(secondRoundUpdatedPage.grant).toBe(Page.GRANT_USER_GROUP);
-          expect(secondRoundUpdatedPage.grantedGroup._id).toStrictEqual(userGroupIdPModelC);
+          expect(normalizeGrantedGroups(secondRoundUpdatedPage.grantedGroups)).toStrictEqual(secondRoundNewGrantedGroups);
         });
         test('Fail to change to GRANT_USER_GROUP if the group to set is NOT the child or descendant of the parent page group', async() => {
           // path
           const _path1 = '/mup31_A';
           const _path2 = '/mup31_A/mup32_owner';
           // page
-          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: userGroupIdPModelA });
+          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroups: { $elemMatch: { item: userGroupIdPModelA } } });
           const _page2 = await Page.findOne({ // update target
             path: _path2, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser1._id], parent: _page1._id,
           });
@@ -1129,7 +1377,13 @@ describe('Page', () => {
           // group parent check
           expect(_groupIsolated.parent).toBeUndefined(); // should have no parent
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelIsolate };
+          const options = {
+            grant: Page.GRANT_USER_GROUP,
+            grantUserGroupIds: [
+              { item: userGroupIdPModelIsolate, type: GroupType.userGroup },
+              { item: externalUserGroupIdPModelIsolate, type: GroupType.externalUserGroup },
+            ],
+          };
           await expect(updatePage(_page2, 'new', 'old', pModelUser1, options)) // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelIsolate)
             .rejects.toThrow(new Error('The selected grant or grantedGroup is not assignable to this page.'));
 
@@ -1140,21 +1394,27 @@ describe('Page', () => {
 
           expect(page2.grant).toBe(Page.GRANT_OWNER); // should be the same before the update
           expect(page2.grantedUsers).toStrictEqual([pModelUser1._id]); // should be the same before the update
-          expect(page2.grantedGroup).toBeUndefined(); // no group should be set
+          expect(page2.grantedGroups.length).toBe(0); // no group should be set
         });
         test('Fail to change to GRANT_USER_GROUP if the group to set is an ancestor of the parent page group', async() => {
           // path
           const _path1 = '/mup33_C';
           const _path2 = '/mup33_C/mup34_owner';
           // page
-          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: userGroupIdPModelC }); // groupC
+          const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroups: { $elemMatch: { item: userGroupIdPModelC } } }); // groupC
           const _page2 = await Page.findOne({ // update target
             path: _path2, grant: Page.GRANT_OWNER, grantedUsers: [pModelUser3], parent: _page1._id,
           });
           expect(_page1).toBeTruthy();
           expect(_page2).toBeTruthy();
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelA };
+          const options = {
+            grant: Page.GRANT_USER_GROUP,
+            grantUserGroupIds: [
+              { item: userGroupIdPModelA, type: GroupType.userGroup },
+              { item: externalUserGroupIdPModelA, type: GroupType.externalUserGroup },
+            ],
+          };
 
           // Group relation(parent -> child): userGroupIdPModelA -> userGroupIdPModelB -> userGroupIdPModelC
           // this should fail because the groupC is a descendant of groupA
@@ -1168,7 +1428,7 @@ describe('Page', () => {
 
           expect(page2.grant).toBe(Page.GRANT_OWNER); // should be the same before the update
           expect(page2.grantedUsers).toStrictEqual([pModelUser3._id]); // should be the same before the update
-          expect(page2.grantedGroup).toBeUndefined(); // no group should be set
+          expect(page2.grantedGroups.length).toBe(0); // no group should be set
         });
       });
       describe('update grant of a page under a page with GRANT_OWNER', () => {
@@ -1184,7 +1444,7 @@ describe('Page', () => {
           expect(_page1).toBeTruthy();
           expect(_page2).toBeTruthy();
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupId: userGroupIdPModelA };
+          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupIds: [{ item: userGroupIdPModelA, type: GroupType.userGroup }] };
           await expect(updatePage(_page2, 'new', 'old', pModelUser1, options)) // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelA)
             .rejects.toThrow(new Error('The selected grant or grantedGroup is not assignable to this page.'));
 
@@ -1194,7 +1454,7 @@ describe('Page', () => {
           expect(page2).toBeTruthy();
           expect(page2.grant).toBe(Page.GRANT_OWNER); // should be the same before the update
           expect(page2.grantedUsers).toStrictEqual([pModelUser1._id]); // should be the same before the update
-          expect(page2.grantedGroup).toBeUndefined(); // no group should be set
+          expect(page2.grantedGroups.length).toBe(0); // no group should be set
         });
       });
 
@@ -1233,7 +1493,7 @@ describe('Page', () => {
       expect(updatedPage.grant).toBe(newGrant);
       // Not changed
       expect(upodPagegBUpdated.grant).toBe(PageGrant.GRANT_USER_GROUP);
-      expect(upodPagegBUpdated.grantedGroup).toStrictEqual(upodPagegB.grantedGroup);
+      expect(upodPagegBUpdated.grantedGroups).toStrictEqual(upodPagegB.grantedGroups);
       expect(upodPageonlyBUpdated.grant).toBe(PageGrant.GRANT_OWNER);
       expect(upodPageonlyBUpdated.grantedUsers).toStrictEqual(upodPageonlyB.grantedUsers);
     });
@@ -1297,7 +1557,10 @@ describe('Page', () => {
       // Update
       const options = {
         grant: PageGrant.GRANT_USER_GROUP,
-        grantUserGroupId: upodUserGroupIdAB,
+        grantUserGroupIds: [
+          { item: upodUserGroupIdAB, type: GroupType.userGroup },
+          { item: upodExternalUserGroupIdAB, type: GroupType.externalUserGroup },
+        ],
         overwriteScopesOfDescendants: true,
       };
       const updatedPage = await updatePage(upodPagePublic, 'newRevisionBody', 'oldRevisionBody', upodUserA, options);
@@ -1308,14 +1571,17 @@ describe('Page', () => {
 
       // Changed
       const newGrant = PageGrant.GRANT_USER_GROUP;
-      const newGrantedGroup = upodUserGroupIdAB;
+      const newGrantedGroups = [
+        { item: upodUserGroupIdAB, type: GroupType.userGroup },
+        { item: upodExternalUserGroupIdAB, type: GroupType.externalUserGroup },
+      ];
       expect(updatedPage.grant).toBe(newGrant);
-      expect(updatedPage.grantedGroup._id).toStrictEqual(newGrantedGroup);
+      expect(normalizeGrantedGroups(updatedPage.grantedGroups)).toStrictEqual(newGrantedGroups);
       expect(upodPagegABUpdated.grant).toBe(newGrant);
-      expect(upodPagegABUpdated.grantedGroup._id).toStrictEqual(newGrantedGroup);
+      expect(normalizeGrantedGroups(upodPagegABUpdated.grantedGroups)).toStrictEqual(newGrantedGroups);
       // Not changed
       expect(upodPagegBUpdated.grant).toBe(PageGrant.GRANT_USER_GROUP);
-      expect(upodPagegBUpdated.grantedGroup._id).toStrictEqual(upodPagegB.grantedGroup);
+      expect(upodPagegBUpdated.grantedGroups).toStrictEqual(upodPagegB.grantedGroups);
       expect(upodPageonlyBUpdated.grant).toBe(PageGrant.GRANT_OWNER);
       expect(upodPageonlyBUpdated.grantedUsers).toStrictEqual(upodPageonlyB.grantedUsers);
     });
@@ -1337,7 +1603,10 @@ describe('Page', () => {
       // Update
       const options = {
         grant: PageGrant.GRANT_USER_GROUP,
-        grantUserGroupId: upodUserGroupIdAB,
+        grantUserGroupIds: [
+          { item: upodUserGroupIdAB, type: GroupType.userGroup },
+          { item: upodExternalUserGroupIdAB, type: GroupType.externalUserGroup },
+        ],
         overwriteScopesOfDescendants: true,
       };
       const updatedPagePromise = updatePage(upodPagePublic, 'newRevisionBody', 'oldRevisionBody', upodUserA, options);
@@ -1362,7 +1631,10 @@ describe('Page', () => {
       // Update
       const options = {
         grant: PageGrant.GRANT_USER_GROUP,
-        grantUserGroupId: upodUserGroupIdAB,
+        grantUserGroupIds: [
+          { item: upodUserGroupIdAB, type: GroupType.userGroup },
+          { item: upodExternalUserGroupIdAB, type: GroupType.externalUserGroup },
+        ],
         overwriteScopesOfDescendants: true,
       };
       const updatedPagePromise = updatePage(upodPagePublic, 'newRevisionBody', 'oldRevisionBody', upodUserA, options);
@@ -1382,7 +1654,10 @@ describe('Page', () => {
       // Update
       const options = {
         grant: PageGrant.GRANT_USER_GROUP,
-        grantUserGroupId: upodUserGroupIdAB,
+        grantUserGroupIds: [
+          { item: upodUserGroupIdAB, type: GroupType.userGroup },
+          { item: upodExternalUserGroupIdAB, type: GroupType.externalUserGroup },
+        ],
         overwriteScopesOfDescendants: true,
       };
       const updatedPagePromise = updatePage(upodPagePublic, 'newRevisionBody', 'oldRevisionBody', upodUserA, options);

+ 266 - 0
apps/app/test/integration/service/external-user-group-sync.test.ts

@@ -0,0 +1,266 @@
+import type { IUserHasId } from '@growi/core';
+import mongoose, { Types } from 'mongoose';
+
+import {
+  ExternalGroupProviderType, ExternalUserGroupTreeNode, IExternalUserGroup, IExternalUserGroupHasId,
+} from '../../../src/features/external-user-group/interfaces/external-user-group';
+import ExternalUserGroup from '../../../src/features/external-user-group/server/models/external-user-group';
+import ExternalUserGroupRelation from '../../../src/features/external-user-group/server/models/external-user-group-relation';
+import ExternalUserGroupSyncService from '../../../src/features/external-user-group/server/service/external-user-group-sync';
+import ExternalAccount from '../../../src/server/models/external-account';
+import { configManager } from '../../../src/server/service/config-manager';
+import { instanciate } from '../../../src/server/service/external-account';
+import PassportService from '../../../src/server/service/passport';
+import { getInstance } from '../setup-crowi';
+
+// dummy class to implement generateExternalUserGroupTrees which returns test data
+class TestExternalUserGroupSyncService extends ExternalUserGroupSyncService {
+
+  constructor(s2sMessagingService, socketIoService) {
+    super('ldap', s2sMessagingService, socketIoService);
+    this.authProviderType = ExternalGroupProviderType.ldap;
+  }
+
+  async generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]> {
+    const childNode: ExternalUserGroupTreeNode = {
+      id: 'cn=childGroup,ou=groups,dc=example,dc=org',
+      userInfos: [{
+        id: 'childGroupUser',
+        username: 'childGroupUser',
+        name: 'Child Group User',
+        email: 'user@childgroup.com',
+      }],
+      childGroupNodes: [],
+      name: 'childGroup',
+      description: 'this is a child group',
+    };
+    const parentNode: ExternalUserGroupTreeNode = {
+      id: 'cn=parentGroup,ou=groups,dc=example,dc=org',
+      // name is undefined
+      userInfos: [{
+        id: 'parentGroupUser',
+        username: 'parentGroupUser',
+        email: 'user@parentgroup.com',
+      }],
+      childGroupNodes: [childNode],
+      name: 'parentGroup',
+      description: 'this is a parent group',
+    };
+    const grandParentNode: ExternalUserGroupTreeNode = {
+      id: 'cn=grandParentGroup,ou=groups,dc=example,dc=org',
+      // email is undefined
+      userInfos: [{
+        id: 'grandParentGroupUser',
+        username: 'grandParentGroupUser',
+        name: 'Grand Parent Group User',
+      }],
+      childGroupNodes: [parentNode],
+      name: 'grandParentGroup',
+      description: 'this is a grand parent group',
+    };
+
+    const previouslySyncedNode: ExternalUserGroupTreeNode = {
+      id: 'cn=previouslySyncedGroup,ou=groups,dc=example,dc=org',
+      userInfos: [{
+        id: 'previouslySyncedGroupUser',
+        username: 'previouslySyncedGroupUser',
+        name: 'Root Group User',
+        email: 'user@previouslySyncedgroup.com',
+      }],
+      childGroupNodes: [],
+      name: 'previouslySyncedGroup',
+      description: 'this is a previouslySynced group',
+    };
+
+    return [grandParentNode, previouslySyncedNode];
+  }
+
+}
+
+const testService = new TestExternalUserGroupSyncService(null, null);
+
+const checkGroup = (group: IExternalUserGroupHasId, expected: Omit<IExternalUserGroup, 'createdAt'>) => {
+  const actual = {
+    name: group.name,
+    parent: group.parent,
+    description: group.description,
+    externalId: group.externalId,
+    provider: group.provider,
+  };
+  expect(actual).toStrictEqual(expected);
+};
+
+const checkSync = async(autoGenerateUserOnGroupSync = true) => {
+  const grandParentGroup = await ExternalUserGroup.findOne({ name: 'grandParentGroup' });
+  checkGroup(grandParentGroup, {
+    externalId: 'cn=grandParentGroup,ou=groups,dc=example,dc=org',
+    name: 'grandParentGroup',
+    description: 'this is a grand parent group',
+    provider: 'ldap',
+    parent: null,
+  });
+  const grandParentGroupRelations = await ExternalUserGroupRelation
+    .find({ relatedGroup: grandParentGroup._id });
+  if (autoGenerateUserOnGroupSync) {
+    expect(grandParentGroupRelations.length).toBe(3);
+    const grandParentGroupUser = (await grandParentGroupRelations[0].populate<{relatedUser: IUserHasId}>('relatedUser'))?.relatedUser;
+    expect(grandParentGroupUser?.username).toBe('grandParentGroupUser');
+    const parentGroupUser = (await grandParentGroupRelations[1].populate<{relatedUser: IUserHasId}>('relatedUser'))?.relatedUser;
+    expect(parentGroupUser?.username).toBe('parentGroupUser');
+    const childGroupUser = (await grandParentGroupRelations[2].populate<{relatedUser: IUserHasId}>('relatedUser'))?.relatedUser;
+    expect(childGroupUser?.username).toBe('childGroupUser');
+  }
+  else {
+    expect(grandParentGroupRelations.length).toBe(0);
+  }
+
+  const parentGroup = await ExternalUserGroup.findOne({ name: 'parentGroup' });
+  checkGroup(parentGroup, {
+    externalId: 'cn=parentGroup,ou=groups,dc=example,dc=org',
+    name: 'parentGroup',
+    description: 'this is a parent group',
+    provider: 'ldap',
+    parent: grandParentGroup._id,
+  });
+  const parentGroupRelations = await ExternalUserGroupRelation
+    .find({ relatedGroup: parentGroup._id });
+  if (autoGenerateUserOnGroupSync) {
+    expect(parentGroupRelations.length).toBe(2);
+    const parentGroupUser = (await parentGroupRelations[0].populate<{relatedUser: IUserHasId}>('relatedUser'))?.relatedUser;
+    expect(parentGroupUser?.username).toBe('parentGroupUser');
+    const childGroupUser = (await parentGroupRelations[1].populate<{relatedUser: IUserHasId}>('relatedUser'))?.relatedUser;
+    expect(childGroupUser?.username).toBe('childGroupUser');
+  }
+  else {
+    expect(parentGroupRelations.length).toBe(0);
+  }
+
+  const childGroup = await ExternalUserGroup.findOne({ name: 'childGroup' });
+  checkGroup(childGroup, {
+    externalId: 'cn=childGroup,ou=groups,dc=example,dc=org',
+    name: 'childGroup',
+    description: 'this is a child group',
+    provider: 'ldap',
+    parent: parentGroup._id,
+  });
+  const childGroupRelations = await ExternalUserGroupRelation
+    .find({ relatedGroup: childGroup._id });
+  if (autoGenerateUserOnGroupSync) {
+    expect(childGroupRelations.length).toBe(1);
+    const childGroupUser = (await childGroupRelations[0].populate<{relatedUser: IUserHasId}>('relatedUser'))?.relatedUser;
+    expect(childGroupUser?.username).toBe('childGroupUser');
+  }
+  else {
+    expect(childGroupRelations.length).toBe(0);
+  }
+
+  const previouslySyncedGroup = await ExternalUserGroup.findOne({ name: 'previouslySyncedGroup' });
+  checkGroup(previouslySyncedGroup, {
+    externalId: 'cn=previouslySyncedGroup,ou=groups,dc=example,dc=org',
+    name: 'previouslySyncedGroup',
+    description: 'this is a previouslySynced group',
+    provider: 'ldap',
+    parent: null,
+  });
+  const previouslySyncedGroupRelations = await ExternalUserGroupRelation
+    .find({ relatedGroup: previouslySyncedGroup._id });
+  if (autoGenerateUserOnGroupSync) {
+    expect(previouslySyncedGroupRelations.length).toBe(1);
+    const previouslySyncedGroupUser = (await previouslySyncedGroupRelations[0].populate<{relatedUser: IUserHasId}>('relatedUser'))?.relatedUser;
+    expect(previouslySyncedGroupUser?.username).toBe('previouslySyncedGroupUser');
+  }
+  else {
+    expect(previouslySyncedGroupRelations.length).toBe(0);
+  }
+};
+
+describe('ExternalUserGroupSyncService.syncExternalUserGroups', () => {
+  let crowi;
+
+  beforeAll(async() => {
+    crowi = await getInstance();
+    const passportService = new PassportService(crowi);
+    instanciate(passportService);
+  });
+
+  beforeEach(async() => {
+    await ExternalUserGroup.create({
+      name: 'nameBeforeEdit',
+      description: 'this is a description before edit',
+      externalId: 'cn=previouslySyncedGroup,ou=groups,dc=example,dc=org',
+      provider: 'ldap',
+    });
+  });
+
+  afterEach(async() => {
+    await ExternalUserGroup.deleteMany();
+    await ExternalUserGroupRelation.deleteMany();
+    await mongoose.model('User')
+      .deleteMany({ username: { $in: ['childGroupUser', 'parentGroupUser', 'grandParentGroupUser', 'previouslySyncedGroupUser'] } });
+    await ExternalAccount.deleteMany({ accountId: { $in: ['childGroupUser', 'parentGroupUser', 'grandParentGroupUser', 'previouslySyncedGroupUser'] } });
+  });
+
+  describe('When autoGenerateUserOnGroupSync is true', () => {
+    const configParams = {
+      'external-user-group:ldap:autoGenerateUserOnGroupSync': true,
+      'external-user-group:ldap:preserveDeletedGroups': false,
+    };
+
+    beforeAll(async() => {
+      await configManager.updateConfigsInTheSameNamespace('crowi', configParams, true);
+    });
+
+    // eslint-disable-next-line jest/expect-expect
+    it('syncs groups with new users', async() => {
+      await testService.syncExternalUserGroups();
+      await checkSync();
+    });
+  });
+
+  describe('When autoGenerateUserOnGroupSync is false', () => {
+    const configParams = {
+      'external-user-group:ldap:autoGenerateUserOnGroupSync': false,
+      'external-user-group:ldap:preserveDeletedGroups': true,
+    };
+
+    beforeAll(async() => {
+      await configManager.updateConfigsInTheSameNamespace('crowi', configParams, true);
+    });
+
+    // eslint-disable-next-line jest/expect-expect
+    it('syncs groups without new users', async() => {
+      await testService.syncExternalUserGroups();
+      await checkSync(false);
+    });
+  });
+
+  describe('When preserveDeletedGroups is false', () => {
+    const configParams = {
+      'external-user-group:ldap:autoGenerateUserOnGroupSync': true,
+      'external-user-group:ldap:preserveDeletedGroups': false,
+    };
+
+    beforeAll(async() => {
+      await configManager.updateConfigsInTheSameNamespace('crowi', configParams, true);
+
+      const groupId = new Types.ObjectId();
+      const userId = new Types.ObjectId();
+
+      await ExternalUserGroup.create({
+        _id: groupId,
+        name: 'non existent group',
+        externalId: 'cn=nonExistentGroup,ou=groups,dc=example,dc=org',
+        provider: 'ldap',
+      });
+      await mongoose.model('User').create({ _id: userId, username: 'nonExistentGroupUser' });
+      await ExternalUserGroupRelation.create({ relatedUser: userId, relatedGroup: groupId });
+    });
+
+    it('syncs groups and deletes groups that do not exist externally', async() => {
+      await testService.syncExternalUserGroups();
+      await checkSync();
+      expect(await ExternalUserGroup.countDocuments()).toBe(4);
+      expect(await ExternalUserGroupRelation.countDocuments()).toBe(7);
+    });
+  });
+});

+ 263 - 0
apps/app/test/integration/service/ldap-user-group-sync.test.ts

@@ -0,0 +1,263 @@
+import ldap, { Client } from 'ldapjs';
+
+import { LdapUserGroupSyncService } from '../../../src/features/external-user-group/server/service/ldap-user-group-sync';
+import { configManager } from '../../../src/server/service/config-manager';
+import { ldapService } from '../../../src/server/service/ldap';
+import PassportService from '../../../src/server/service/passport';
+import { getInstance } from '../setup-crowi';
+
+describe('LdapUserGroupSyncService.generateExternalUserGroupTrees', () => {
+  let crowi;
+  let ldapUserGroupSyncService: LdapUserGroupSyncService;
+
+  const configParams = {
+    'security:passport-ldap:attrMapName': 'name',
+    'external-user-group:ldap:groupChildGroupAttribute': 'member',
+    'external-user-group:ldap:groupMembershipAttribute': 'member',
+    'external-user-group:ldap:groupNameAttribute': 'cn',
+    'external-user-group:ldap:groupDescriptionAttribute': 'description',
+    'external-user-group:ldap:groupMembershipAttributeType': 'DN',
+    'external-user-group:ldap:groupSearchBase': 'ou=groups,dc=example,dc=org',
+    'security:passport-ldap:serverUrl': 'ldap://openldap:1389/dc=example,dc=org',
+  };
+
+  jest.mock('../../../src/server/service/ldap');
+  const mockBind = jest.spyOn(ldapService, 'bind');
+  const mockLdapSearch = jest.spyOn(ldapService, 'search');
+  const mockLdapCreateClient = jest.spyOn(ldap, 'createClient');
+
+  beforeAll(async() => {
+    crowi = await getInstance();
+    await configManager.updateConfigsInTheSameNamespace('crowi', configParams, true);
+
+    mockBind.mockImplementation(() => {
+      return Promise.resolve();
+    });
+    mockLdapCreateClient.mockImplementation(() => { return {} as Client });
+
+    const passportService = new PassportService(crowi);
+    ldapUserGroupSyncService = new LdapUserGroupSyncService(passportService, null, null);
+  });
+
+  describe('When there is no circular reference in group tree', () => {
+    it('creates ExternalUserGroupTrees', async() => {
+      // mock search on LDAP server
+      mockLdapSearch.mockImplementation((filter, base) => {
+        if (base === 'ou=groups,dc=example,dc=org') {
+        // search groups
+          return Promise.resolve([
+            {
+              objectName: 'cn=childGroup,ou=groups,dc=example,dc=org',
+              attributes: [
+                { type: 'cn', values: ['childGroup'] },
+                { type: 'description', values: ['this is a child group'] },
+                {
+                  type: 'member',
+                  values: ['cn=childGroupUser,ou=users,dc=example,dc=org'],
+                },
+              ],
+            },
+            {
+              objectName: 'cn=parentGroup,ou=groups,dc=example,dc=org',
+              attributes: [
+                { type: 'cn', values: ['parentGroup'] },
+                { type: 'description', values: ['this is a parent group'] },
+                {
+                  type: 'member',
+                  values: ['cn=childGroup,ou=groups,dc=example,dc=org', 'cn=parentGroupUser,ou=users,dc=example,dc=org'],
+                },
+              ],
+            },
+            // root node
+            {
+              objectName: 'cn=grandParentGroup,ou=groups,dc=example,dc=org',
+              attributes: [
+                { type: 'cn', values: ['grandParentGroup'] },
+                { type: 'description', values: ['this is a grand parent group'] },
+                {
+                  type: 'member',
+                  values: ['cn=parentGroup,ou=groups,dc=example,dc=org', 'cn=grandParentGroupUser,ou=users,dc=example,dc=org'],
+                },
+              ],
+            },
+            // another root node
+            {
+              objectName: 'cn=rootGroup,ou=groups,dc=example,dc=org',
+              attributes: [
+                { type: 'cn', values: ['rootGroup'] },
+                { type: 'description', values: ['this is a root group'] },
+                {
+                  type: 'member',
+                  values: ['cn=rootGroupUser,ou=users,dc=example,dc=org'],
+                },
+              ],
+            },
+          ]);
+        }
+        if (base === 'cn=childGroupUser,ou=users,dc=example,dc=org') {
+        // search childGroupUser
+          return Promise.resolve([
+            {
+              objectName: 'cn=childGroupUser,ou=users,dc=example,dc=org',
+              attributes: [
+                { type: 'name', values: ['Child Group User'] },
+                { type: 'uid', values: ['childGroupUser'] },
+                { type: 'mail', values: ['user@childGroup.com'] },
+              ],
+            },
+          ]);
+        }
+        // search parentGroupUser
+        if (base === 'cn=parentGroupUser,ou=users,dc=example,dc=org') {
+          return Promise.resolve([
+            {
+              objectName: 'cn=parentGroupUser,ou=users,dc=example,dc=org',
+              attributes: [
+                { type: 'name', values: ['Parent Group User'] },
+                { type: 'uid', values: ['parentGroupUser'] },
+                { type: 'mail', values: ['user@parentGroup.com'] },
+              ],
+            },
+          ]);
+        }
+        // search grandParentGroupUser
+        if (base === 'cn=grandParentGroupUser,ou=users,dc=example,dc=org') {
+          return Promise.resolve([
+            {
+              objectName: 'cn=grandParentGroupUser,ou=users,dc=example,dc=org',
+              attributes: [
+                { type: 'name', values: ['Grand Parent Group User'] },
+                { type: 'uid', values: ['grandParentGroupUser'] },
+                { type: 'mail', values: ['user@grandParentGroup.com'] },
+              ],
+            },
+          ]);
+        }
+        // search rootGroupUser
+        if (base === 'cn=rootGroupUser,ou=users,dc=example,dc=org') {
+          return Promise.resolve([
+            {
+              objectName: 'cn=rootGroupUser,ou=users,dc=example,dc=org',
+              attributes: [
+                { type: 'name', values: ['Root Group User'] },
+                { type: 'uid', values: ['rootGroupUser'] },
+                { type: 'mail', values: ['user@rootGroup.com'] },
+              ],
+            },
+          ]);
+        }
+        return Promise.reject(new Error('not found'));
+      });
+
+      const rootNodes = await ldapUserGroupSyncService?.generateExternalUserGroupTrees();
+
+      expect(rootNodes?.length).toBe(2);
+
+      // check grandParentGroup
+      const grandParentNode = rootNodes?.find(node => node.id === 'cn=grandParentGroup,ou=groups,dc=example,dc=org');
+      const expectedChildNode = {
+        id: 'cn=childGroup,ou=groups,dc=example,dc=org',
+        userInfos: [{
+          id: 'childGroupUser',
+          username: 'childGroupUser',
+          name: 'Child Group User',
+          email: 'user@childGroup.com',
+        }],
+        childGroupNodes: [],
+        name: 'childGroup',
+        description: 'this is a child group',
+      };
+      const expectedParentNode = {
+        id: 'cn=parentGroup,ou=groups,dc=example,dc=org',
+        userInfos: [{
+          id: 'parentGroupUser',
+          username: 'parentGroupUser',
+          name: 'Parent Group User',
+          email: 'user@parentGroup.com',
+        }],
+        childGroupNodes: [expectedChildNode],
+        name: 'parentGroup',
+        description: 'this is a parent group',
+      };
+      const expectedGrandParentNode = {
+        id: 'cn=grandParentGroup,ou=groups,dc=example,dc=org',
+        userInfos: [{
+          id: 'grandParentGroupUser',
+          username: 'grandParentGroupUser',
+          name: 'Grand Parent Group User',
+          email: 'user@grandParentGroup.com',
+        }],
+        childGroupNodes: [expectedParentNode],
+        name: 'grandParentGroup',
+        description: 'this is a grand parent group',
+      };
+      expect(grandParentNode).toStrictEqual(expectedGrandParentNode);
+
+      // check rootGroup
+      const rootNode = rootNodes?.find(node => node.id === 'cn=rootGroup,ou=groups,dc=example,dc=org');
+      const expectedRootNode = {
+        id: 'cn=rootGroup,ou=groups,dc=example,dc=org',
+        userInfos: [{
+          id: 'rootGroupUser',
+          username: 'rootGroupUser',
+          name: 'Root Group User',
+          email: 'user@rootGroup.com',
+        }],
+        childGroupNodes: [],
+        name: 'rootGroup',
+        description: 'this is a root group',
+      };
+      expect(rootNode).toStrictEqual(expectedRootNode);
+    });
+  });
+
+  describe('When there is a circular reference in group tree', () => {
+    it('rejects creating ExternalUserGroupTrees', async() => {
+      // mock search on LDAP server
+      mockLdapSearch.mockImplementation((filter, base) => {
+        if (base === 'ou=groups,dc=example,dc=org') {
+        // search groups
+          return Promise.resolve([
+          // childGroup and parentGroup have circular reference
+            {
+              objectName: 'cn=childGroup,ou=groups,dc=example,dc=org',
+              attributes: [
+                { type: 'cn', values: ['childGroup'] },
+                { type: 'description', values: ['this is a child group'] },
+                {
+                  type: 'member',
+                  values: ['cn=parentGroup,ou=groups,dc=example,dc=org'],
+                },
+              ],
+            },
+            {
+              objectName: 'cn=parentGroup,ou=groups,dc=example,dc=org',
+              attributes: [
+                { type: 'cn', values: ['parentGroup'] },
+                { type: 'description', values: ['this is a parent group'] },
+                {
+                  type: 'member',
+                  values: ['cn=childGroup,ou=groups,dc=example,dc=org'],
+                },
+              ],
+            },
+            {
+              objectName: 'cn=grandParentGroup,ou=groups,dc=example,dc=org',
+              attributes: [
+                { type: 'cn', values: ['grandParentGroup'] },
+                { type: 'description', values: ['this is a grand parent group'] },
+                {
+                  type: 'member',
+                  values: ['cn=parentGroup,ou=groups,dc=example,dc=org'],
+                },
+              ],
+            },
+          ]);
+        }
+        return Promise.reject(new Error('not found'));
+      });
+
+      await expect(ldapUserGroupSyncService?.generateExternalUserGroupTrees()).rejects.toThrow('Circular reference inside LDAP group tree');
+    });
+  });
+});

+ 111 - 42
apps/app/test/integration/service/page-grant.test.js

@@ -1,10 +1,14 @@
-import { PageGrant } from '@growi/core';
+import { GroupType, PageGrant } from '@growi/core';
 import mongoose from 'mongoose';
 
+import { ExternalGroupProviderType } from '~/features/external-user-group/interfaces/external-user-group';
+import ExternalUserGroup from '~/features/external-user-group/server/models/external-user-group';
+import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import UserGroup from '~/server/models/user-group';
 
 import { getInstance } from '../setup-crowi';
 
+
 /*
  * There are 3 grant types to test.
  * GRANT_PUBLIC, GRANT_OWNER, GRANT_USER_GROUP
@@ -30,7 +34,11 @@ describe('PageGrantService', () => {
   let groupParent;
   let groupChild;
 
+  let externalGroupParent;
+  let externalGroupChild;
+
   const userGroupIdParent = new mongoose.Types.ObjectId();
+  const externalUserGroupIdParent = new mongoose.Types.ObjectId();
 
   let rootPage;
   let rootPublicPage;
@@ -115,6 +123,40 @@ describe('PageGrantService', () => {
       },
     ]);
 
+    await ExternalUserGroup.insertMany([
+      {
+        _id: externalUserGroupIdParent,
+        name: 'ExternalGroupParent',
+        externalId: 'ExternalGroupParent',
+        provider: ExternalGroupProviderType.ldap,
+        parent: null,
+      },
+      {
+        name: 'ExternalGroupChild',
+        externalId: 'ExternalGroupChild',
+        provider: ExternalGroupProviderType.ldap,
+        parent: externalUserGroupIdParent,
+      },
+    ]);
+
+    externalGroupParent = await ExternalUserGroup.findOne({ name: 'ExternalGroupParent' });
+    externalGroupChild = await ExternalUserGroup.findOne({ name: 'ExternalGroupChild' });
+
+    await ExternalUserGroupRelation.insertMany([
+      {
+        relatedGroup: externalGroupParent._id,
+        relatedUser: user1._id,
+      },
+      {
+        relatedGroup: externalGroupParent._id,
+        relatedUser: user2._id,
+      },
+      {
+        relatedGroup: externalGroupChild._id,
+        relatedUser: user1._id,
+      },
+    ]);
+
     // Root page (Depth: 0)
     rootPage = await Page.findOne({ path: '/' });
 
@@ -144,7 +186,7 @@ describe('PageGrantService', () => {
         creator: user1,
         lastUpdateUser: user1,
         grantedUsers: null,
-        grantedGroup: null,
+        grantedGroups: null,
         parent: rootPage._id,
       },
       {
@@ -153,7 +195,7 @@ describe('PageGrantService', () => {
         creator: user1,
         lastUpdateUser: user1,
         grantedUsers: null,
-        grantedGroup: groupParent._id,
+        grantedGroups: [{ item: groupParent._id, type: GroupType.userGroup }, { item: externalGroupParent._id, type: GroupType.externalUserGroup }],
         parent: rootPage._id,
       },
     ]);
@@ -183,7 +225,7 @@ describe('PageGrantService', () => {
         path: v4PageRootOnlyInsideTheGroupPagePath,
         grant: Page.GRANT_USER_GROUP,
         parent: null,
-        grantedGroup: groupParent._id,
+        grantedGroups: [{ item: groupParent._id, type: GroupType.userGroup }, { item: externalGroupParent._id, type: GroupType.externalUserGroup }],
       },
     ]);
 
@@ -262,7 +304,7 @@ describe('PageGrantService', () => {
         creator: user1,
         lastUpdateUser: user1,
         grantedUsers: null,
-        grantedGroup: null,
+        grantedGroups: null,
         parent: emptyPage1._id,
       },
       {
@@ -271,7 +313,7 @@ describe('PageGrantService', () => {
         creator: user1,
         lastUpdateUser: user1,
         grantedUsers: [user1._id],
-        grantedGroup: null,
+        grantedGroups: null,
         parent: emptyPage2._id,
       },
       {
@@ -280,7 +322,7 @@ describe('PageGrantService', () => {
         creator: user1,
         lastUpdateUser: user1,
         grantedUsers: null,
-        grantedGroup: groupParent._id,
+        grantedGroups: [{ item: groupParent._id, type: GroupType.userGroup }, { item: externalGroupParent._id, type: GroupType.externalUserGroup }],
         parent: emptyPage3._id,
       },
       {
@@ -289,7 +331,7 @@ describe('PageGrantService', () => {
         creator: user1,
         lastUpdateUser: user1,
         grantedUsers: null,
-        grantedGroup: groupChild._id,
+        grantedGroups: [{ item: groupChild._id, type: GroupType.userGroup }, { item: externalGroupChild._id, type: GroupType.externalUserGroup }],
         parent: emptyPage3._id,
       },
       {
@@ -298,7 +340,7 @@ describe('PageGrantService', () => {
         creator: user1,
         lastUpdateUser: user1,
         grantedUsers: [user1._id],
-        grantedGroup: null,
+        grantedGroups: null,
         parent: emptyPage3._id,
       },
     ]);
@@ -333,10 +375,10 @@ describe('PageGrantService', () => {
       const targetPath = '/NEW';
       const grant = Page.GRANT_PUBLIC;
       const grantedUserIds = null;
-      const grantedGroupId = null;
+      const grantedGroupIds = null;
       const shouldCheckDescendants = false;
 
-      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
 
       expect(result).toBe(true);
     });
@@ -345,10 +387,10 @@ describe('PageGrantService', () => {
       const targetPath = '/NEW_GroupParent';
       const grant = Page.GRANT_USER_GROUP;
       const grantedUserIds = null;
-      const grantedGroupId = groupParent._id;
+      const grantedGroupIds = [{ item: groupParent._id, type: GroupType.userGroup }, { item: externalGroupParent._id, type: GroupType.externalUserGroup }];
       const shouldCheckDescendants = false;
 
-      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
 
       expect(result).toBe(true);
     });
@@ -357,10 +399,10 @@ describe('PageGrantService', () => {
       const targetPath = `${pageRootPublicPath}/NEW`;
       const grant = Page.GRANT_PUBLIC;
       const grantedUserIds = null;
-      const grantedGroupId = null;
+      const grantedGroupIds = null;
       const shouldCheckDescendants = false;
 
-      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
 
       expect(result).toBe(true);
     });
@@ -369,10 +411,10 @@ describe('PageGrantService', () => {
       const targetPath = `${pageRootGroupParentPath}/NEW`;
       const grant = Page.GRANT_USER_GROUP;
       const grantedUserIds = null;
-      const grantedGroupId = groupParent._id;
+      const grantedGroupIds = [{ item: groupParent._id, type: GroupType.userGroup }, { item: externalGroupParent._id, type: GroupType.externalUserGroup }];
       const shouldCheckDescendants = false;
 
-      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
 
       expect(result).toBe(true);
     });
@@ -381,10 +423,10 @@ describe('PageGrantService', () => {
       const targetPath = `${pageE1PublicPath}/NEW`;
       const grant = Page.GRANT_PUBLIC;
       const grantedUserIds = null;
-      const grantedGroupId = null;
+      const grantedGroupIds = null;
       const shouldCheckDescendants = false;
 
-      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
 
       expect(result).toBe(true);
     });
@@ -393,10 +435,10 @@ describe('PageGrantService', () => {
       const targetPath = `${pageE2User1Path}/NEW`;
       const grant = Page.GRANT_OWNER;
       const grantedUserIds = [user1._id];
-      const grantedGroupId = null;
+      const grantedGroupIds = null;
       const shouldCheckDescendants = false;
 
-      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
 
       expect(result).toBe(true);
     });
@@ -405,10 +447,10 @@ describe('PageGrantService', () => {
       const targetPath = `${pageE3GroupParentPath}/NEW`;
       const grant = Page.GRANT_PUBLIC;
       const grantedUserIds = null;
-      const grantedGroupId = null;
+      const grantedGroupIds = null;
       const shouldCheckDescendants = false;
 
-      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
 
       expect(result).toBe(false);
     });
@@ -417,10 +459,10 @@ describe('PageGrantService', () => {
       const targetPath = `${pageE3GroupChildPath}/NEW`;
       const grant = Page.GRANT_USER_GROUP;
       const grantedUserIds = null;
-      const grantedGroupId = groupParent._id;
+      const grantedGroupIds = [{ item: groupParent._id, type: GroupType.userGroup }, { item: externalGroupParent._id, type: GroupType.externalUserGroup }];
       const shouldCheckDescendants = false;
 
-      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
 
       expect(result).toBe(false);
     });
@@ -431,10 +473,10 @@ describe('PageGrantService', () => {
       const targetPath = emptyPagePath1;
       const grant = Page.GRANT_PUBLIC;
       const grantedUserIds = null;
-      const grantedGroupId = null;
+      const grantedGroupIds = null;
       const shouldCheckDescendants = true;
 
-      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
 
       expect(result).toBe(true);
     });
@@ -443,10 +485,10 @@ describe('PageGrantService', () => {
       const targetPath = emptyPagePath2;
       const grant = Page.GRANT_OWNER;
       const grantedUserIds = [user1._id];
-      const grantedGroupId = null;
+      const grantedGroupIds = null;
       const shouldCheckDescendants = true;
 
-      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
 
       expect(result).toBe(true);
     });
@@ -455,10 +497,10 @@ describe('PageGrantService', () => {
       const targetPath = emptyPagePath3;
       const grant = Page.GRANT_USER_GROUP;
       const grantedUserIds = null;
-      const grantedGroupId = groupParent._id;
+      const grantedGroupIds = [{ item: groupParent._id, type: GroupType.userGroup }, { item: externalGroupParent._id, type: GroupType.externalUserGroup }];
       const shouldCheckDescendants = true;
 
-      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
 
       expect(result).toBe(true);
     });
@@ -467,10 +509,10 @@ describe('PageGrantService', () => {
       const targetPath = emptyPagePath1;
       const grant = Page.GRANT_OWNER;
       const grantedUserIds = [user1._id];
-      const grantedGroupId = null;
+      const grantedGroupIds = null;
       const shouldCheckDescendants = true;
 
-      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
 
       expect(result).toBe(false);
     });
@@ -490,7 +532,16 @@ describe('PageGrantService', () => {
 
     // parent property of all private pages is null
     test('Any grant is allowed if parent is null', async() => {
-      const userGroupRelation = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user1);
+      const userPossessedUserGroups = await UserGroupRelation.findAllGroupsForUser(user1);
+      const userPossessedExternalUserGroups = await ExternalUserGroupRelation.findAllGroupsForUser(user1);
+      const userPossessedGroups = [
+        ...userPossessedUserGroups.map((group) => {
+          return { type: GroupType.userGroup, item: group };
+        }),
+        ...userPossessedExternalUserGroups.map((group) => {
+          return { type: GroupType.externalUserGroup, item: group };
+        }),
+      ];
 
       // OnlyMe
       const rootOnlyMePage = await Page.findOne({ path: v4PageRootOnlyMePagePath });
@@ -500,7 +551,7 @@ describe('PageGrantService', () => {
           [PageGrant.GRANT_PUBLIC]: null,
           [PageGrant.GRANT_RESTRICTED]: null,
           [PageGrant.GRANT_OWNER]: null,
-          [PageGrant.GRANT_USER_GROUP]: userGroupRelation,
+          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userPossessedGroups },
         },
       );
 
@@ -512,7 +563,7 @@ describe('PageGrantService', () => {
           [PageGrant.GRANT_PUBLIC]: null,
           [PageGrant.GRANT_RESTRICTED]: null,
           [PageGrant.GRANT_OWNER]: null,
-          [PageGrant.GRANT_USER_GROUP]: userGroupRelation,
+          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userPossessedGroups },
         },
       );
 
@@ -524,14 +575,23 @@ describe('PageGrantService', () => {
           [PageGrant.GRANT_PUBLIC]: null,
           [PageGrant.GRANT_RESTRICTED]: null,
           [PageGrant.GRANT_OWNER]: null,
-          [PageGrant.GRANT_USER_GROUP]: userGroupRelation,
+          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userPossessedGroups },
         },
       );
     });
 
 
     test('Any grant is allowed if parent is public', async() => {
-      const userGroupRelation = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user1);
+      const userPossessedUserGroups = await UserGroupRelation.findAllGroupsForUser(user1);
+      const userPossessedExternalUserGroups = await ExternalUserGroupRelation.findAllGroupsForUser(user1);
+      const userPossessedGroups = [
+        ...userPossessedUserGroups.map((group) => {
+          return { type: GroupType.userGroup, item: group };
+        }),
+        ...userPossessedExternalUserGroups.map((group) => {
+          return { type: GroupType.externalUserGroup, item: group };
+        }),
+      ];
 
       // OnlyMe
       const publicOnlyMePage = await Page.findOne({ path: pagePublicOnlyMePath });
@@ -541,7 +601,7 @@ describe('PageGrantService', () => {
           [PageGrant.GRANT_PUBLIC]: null,
           [PageGrant.GRANT_RESTRICTED]: null,
           [PageGrant.GRANT_OWNER]: null,
-          [PageGrant.GRANT_USER_GROUP]: userGroupRelation,
+          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userPossessedGroups },
         },
       );
 
@@ -553,7 +613,7 @@ describe('PageGrantService', () => {
           [PageGrant.GRANT_PUBLIC]: null,
           [PageGrant.GRANT_RESTRICTED]: null,
           [PageGrant.GRANT_OWNER]: null,
-          [PageGrant.GRANT_USER_GROUP]: userGroupRelation,
+          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userPossessedGroups },
         },
       );
 
@@ -565,7 +625,7 @@ describe('PageGrantService', () => {
           [PageGrant.GRANT_PUBLIC]: null,
           [PageGrant.GRANT_RESTRICTED]: null,
           [PageGrant.GRANT_OWNER]: null,
-          [PageGrant.GRANT_USER_GROUP]: userGroupRelation,
+          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userPossessedGroups },
         },
       );
     });
@@ -633,7 +693,16 @@ describe('PageGrantService', () => {
     });
 
     test('"GRANT_USER_GROUP" is allowed if the parent\'s grant is GRANT_USER_GROUP and the user is included in the group', async() => {
-      const applicableGroups = await UserGroupRelation.findGroupsWithDescendantsByGroupAndUser(groupParent, user1);
+      const userGroups = await UserGroupRelation.findGroupsWithDescendantsByGroupAndUser(groupParent, user1);
+      const externalUserGroups = await ExternalUserGroupRelation.findGroupsWithDescendantsByGroupAndUser(externalGroupParent, user1);
+      const applicableGroups = [
+        ...userGroups.map((group) => {
+          return { type: GroupType.userGroup, item: group };
+        }),
+        ...externalUserGroups.map((group) => {
+          return { type: GroupType.externalUserGroup, item: group };
+        }),
+      ];
 
       // Public
       const onlyInsideGroupPublicPage = await Page.findOne({ path: pageOnlyInsideTheGroupPublicPath });

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません