Просмотр исходного кода

Merge pull request #8331 from weseek/feat/multiple-group-grant-for-page

feat: Multiple group grant for page
Yuki Takei 2 лет назад
Родитель
Сommit
8b364f8b92
44 измененных файлов с 1239 добавлено и 379 удалено
  1. 2 2
      apps/app/_obsolete/src/components/PageEditorByHackmd.tsx
  2. 9 7
      apps/app/public/static/locales/en_US/admin.json
  3. 6 3
      apps/app/public/static/locales/en_US/translation.json
  4. 9 7
      apps/app/public/static/locales/ja_JP/admin.json
  5. 6 3
      apps/app/public/static/locales/ja_JP/translation.json
  6. 9 7
      apps/app/public/static/locales/zh_CN/admin.json
  7. 6 3
      apps/app/public/static/locales/zh_CN/translation.json
  8. 10 0
      apps/app/src/client/services/AdminGeneralSecurityContainer.js
  9. 1 1
      apps/app/src/components/Admin/AuditLog/AuditLogDisableMode.tsx
  10. 2 2
      apps/app/src/components/Admin/AuditLog/AuditLogSettings.tsx
  11. 24 3
      apps/app/src/components/Admin/Security/SecuritySetting.jsx
  12. 62 30
      apps/app/src/components/PageAlert/FixPageGrantAlert.tsx
  13. 5 1
      apps/app/src/components/PageAlert/PageGrantAlert.tsx
  14. 27 9
      apps/app/src/components/PageDuplicateModal.tsx
  15. 2 2
      apps/app/src/components/PageEditor/PageEditor.tsx
  16. 4 0
      apps/app/src/components/PageManagement/ApiErrorMessage.jsx
  17. 2 2
      apps/app/src/components/SavePageControls.tsx
  18. 40 18
      apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx
  19. 0 1
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/SyncExecution.tsx
  20. 1 1
      apps/app/src/interfaces/page-operation.ts
  21. 1 1
      apps/app/src/interfaces/page.ts
  22. 1 1
      apps/app/src/migrations/20230723061824-granted-group-to-array-of-objects.js
  23. 28 0
      apps/app/src/migrations/20231223155127-non-null-granted-groups.js
  24. 12 7
      apps/app/src/pages/[[...path]].page.tsx
  25. 4 3
      apps/app/src/server/crowi/index.js
  26. 1 0
      apps/app/src/server/models/config.ts
  27. 1 1
      apps/app/src/server/models/interfaces/page-operation.ts
  28. 1 25
      apps/app/src/server/models/obsolete-page.js
  29. 1 0
      apps/app/src/server/models/page.ts
  30. 9 1
      apps/app/src/server/routes/apiv3/page-listing.ts
  31. 9 7
      apps/app/src/server/routes/apiv3/page.js
  32. 2 2
      apps/app/src/server/routes/apiv3/pages.js
  33. 5 0
      apps/app/src/server/routes/apiv3/security-settings/index.js
  34. 6 9
      apps/app/src/server/routes/page.js
  35. 147 16
      apps/app/src/server/service/page-grant.ts
  36. 244 88
      apps/app/src/server/service/page/index.ts
  37. 5 7
      apps/app/src/server/service/search-delegator/elasticsearch.ts
  38. 124 29
      apps/app/test/integration/models/v5.page.test.js
  39. 108 13
      apps/app/test/integration/service/page-grant.test.js
  40. 184 48
      apps/app/test/integration/service/page.test.js
  41. 114 9
      apps/app/test/integration/service/v5.non-public-page.test.ts
  42. 4 4
      apps/app/test/integration/service/v5.page.test.ts
  43. 1 1
      apps/app/test/integration/service/v5.public-page.test.ts
  44. 0 5
      packages/core/src/interfaces/page.ts

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

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

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

@@ -38,10 +38,12 @@
     "page_delete": "Page Delete",
     "page_delete_completely": "Page Delete Completely",
     "other_options": "Other options",
-    "deletion_explain": "Restricts users who can trash the selected single page.",
-    "complete_deletion_explain": "Restricts users who can completely delete  selected single page.",
-    "recursive_deletion_explain": "Restricts users who can trash pages including descendants.",
-    "recursive_complete_deletion_explain": "Restricts users who can completely delete pages including descendants.",
+    "deletion_explanation": "Restricts users who can trash the selected single page.",
+    "complete_deletion_explanation": "Restricts users who can completely delete  selected single page.",
+    "recursive_deletion_explanation": "Restricts users who can trash pages including descendants.",
+    "recursive_complete_deletion_explanation": "Restricts users who can completely delete pages including descendants.",
+    "is_all_group_membership_required_for_page_complete_deletion": "Users other than admin and page author are required to belong to all groups that are granted page access",
+    "is_all_group_membership_required_for_page_complete_deletion_explanation": "Effective when page access settings is set to \"Only specific groups\".",
     "inherit": "Inherit(Use the same setting as for a single page)",
     "admin_only": "Admin only",
     "admin_and_author": "Admin and author",
@@ -857,12 +859,12 @@
     "return": "Return",
     "clear": "Clear",
     "activity_expiration_date": "Audit Log expiration date",
-    "activity_expiration_date_explain": "Created Audit Log are automatically deleted after the number of seconds set in the environment variable from the creation time",
+    "activity_expiration_date_explanation": "Created Audit Log are automatically deleted after the number of seconds set in the environment variable from the creation time",
     "fixed_by_env_var": "This is fixed by the env var <code>{{key}}={{value}}</code>.",
     "available_action_list": "Search / View All Available Actions",
-    "available_action_list_explain": "List of actions that can be searched/viewed in the current settings",
+    "available_action_list_explanation": "List of actions that can be searched/viewed in the current settings",
     "action_list": "Action List",
-    "disable_mode_explain": "Audit log is currently disabled. To enable it, set the environment variable <code>AUDIT_LOG_ENABLED</code> to true.",
+    "disable_mode_explanation": "Audit log is currently disabled. To enable it, set the environment variable <code>AUDIT_LOG_ENABLED</code> to true.",
     "docs_url": {
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }

+ 6 - 3
apps/app/public/static/locales/en_US/translation.json

@@ -328,7 +328,8 @@
     "already_exists": "Page with the path already exists.",
     "outdated": "Page is updated someone and now outdated.",
     "user_not_admin": "Only admin user can delete",
-    "single_deletion_empty_pages": "Empty pages cannot be single deleted"
+    "single_deletion_empty_pages": "Empty pages cannot be single deleted",
+    "complete_deletion_not_allowed_for_user": "You are not allowed to delete this page completely"
   },
   "page_history": {
     "revision_list": "Revision list",
@@ -389,10 +390,12 @@
       "Current page name": "Current page name",
       "Recursively": "Recursively",
       "Duplicate without exist path": "Duplicate without exist path",
-      "Same page already exists": "Same page already exists"
+      "Same page already exists": "Same page already exists",
+      "Only duplicate user related resources": "Only duplicate user related resources"
     },
     "help": {
-      "recursive": "Duplicate children of under this path recursively"
+      "recursive": "Duplicate children of under this path recursively",
+      "only_user_related_resources": "This will only duplicate pages that the user has permission to view. If the page permission is set to \"Only specific groups\", only user related groups will be set to the page duplicate."
     }
   },
   "duplicated_pages": "{{fromPath}} has been duplicated",

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

@@ -47,10 +47,12 @@
     "page_delete": "ゴミ箱に入れる",
     "page_delete_completely": "完全に削除する",
     "other_options": "その他のオプション",
-    "deletion_explain": "ページをゴミ箱に入れることができるユーザーを制限します。",
-    "complete_deletion_explain": "ページを完全削除することができるユーザーを制限します。",
-    "recursive_deletion_explain": "子孫を含めたページをゴミ箱に入れることができるユーザーを制限します。",
-    "recursive_complete_deletion_explain": "子孫を含めたページを完全削除することができるユーザーを制限します。",
+    "deletion_explanation": "ページをゴミ箱に入れることができるユーザーを制限します。",
+    "complete_deletion_explanation": "ページを完全削除することができるユーザーを制限します。",
+    "recursive_deletion_explanation": "子孫を含めたページをゴミ箱に入れることができるユーザーを制限します。",
+    "recursive_complete_deletion_explanation": "子孫を含めたページを完全削除することができるユーザーを制限します。",
+    "is_all_group_membership_required_for_page_complete_deletion": "管理者とページ作者以外はページに対する権限を持つ全てのグループに所属している必要がある",
+    "is_all_group_membership_required_for_page_complete_deletion_explanation": "ページの権限設定が「特定のグループのみ」の場合有効になります。",
     "inherit": "単体のみと同じ",
     "admin_only": "管理者のみ可能",
     "admin_and_author": "管理者とページ作者が可能",
@@ -867,12 +869,12 @@
     "return": "戻る",
     "clear": "クリア",
     "activity_expiration_date": "監査ログの有効期限",
-    "activity_expiration_date_explain": "作成された監査ログは、作成時間から環境変数に設定した秒数後に自動的に削除されます",
+    "activity_expiration_date_explanation": "作成された監査ログは、作成時間から環境変数に設定した秒数後に自動的に削除されます",
     "fixed_by_env_var": "環境変数により固定されています <code>{{key}}={{value}}</code>.",
     "available_action_list": "検索 / 表示 可能なアクション一覧",
-    "available_action_list_explain": "現在の設定で検索 / 表示 可能なアクション一覧です",
+    "available_action_list_explanation": "現在の設定で検索 / 表示 可能なアクション一覧です",
     "action_list": "アクション一覧",
-    "disable_mode_explain": "現在、監査ログは無効になっています。有効にする場合は環境変数 <code>AUDIT_LOG_ENABLED</code> を true に設定してください。",
+    "disable_mode_explanation": "現在、監査ログは無効になっています。有効にする場合は環境変数 <code>AUDIT_LOG_ENABLED</code> を true に設定してください。",
     "docs_url": {
       "log_type": "https://docs.growi.org/ja/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }

+ 6 - 3
apps/app/public/static/locales/ja_JP/translation.json

@@ -361,7 +361,8 @@
     "already_exists": "そのパスを持つページは既に存在しています。",
     "outdated": "ページが他のユーザーによって更新されました。",
     "user_not_admin": "権限のあるユーザーのみが削除できます",
-    "single_deletion_empty_pages": "空ページの単体削除はできません"
+    "single_deletion_empty_pages": "空ページの単体削除はできません",
+    "complete_deletion_not_allowed_for_user": "ページを完全に削除する権限がありません"
   },
   "page_history": {
     "revision_list": "更新履歴",
@@ -422,10 +423,12 @@
       "Current page name": "現在のページ名",
       "Recursively": "再帰的に複製",
       "Duplicate without exist path": "存在するパス以外を複製する",
-      "Same page already exists": "同じページがすでに存在します"
+      "Same page already exists": "同じページがすでに存在します",
+      "Only duplicate user related resources": "ユーザに関連のあるリソースのみを複製する"
     },
     "help": {
-      "recursive": "配下のページも複製します"
+      "recursive": "配下のページも複製します",
+      "only_user_related_resources": "ユーザが閲覧可能なページのみを複製します。また、閲覧権限が「特定グループのみ」で設定されている場合、複製後のページにはユーザが所属するグループのみを閲覧可能なグループとして設定します。"
     }
   },
   "duplicated_pages": "{{fromPath}} を複製しました",

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

@@ -47,10 +47,12 @@
     "page_delete": "删除",
     "page_delete_completely": "彻底删除",
     "other_options": "其他选项",
-    "deletion_explain": "限制用户对选定的单一页面进行垃圾处理。",
-    "complete_deletion_explain": "限制可以完全删除所选单页的用户。",
-    "recursive_deletion_explain": "限制用户可以捣毁包括子孙在内的页面。",
-    "recursive_complete_deletion_explain": "限制可以完全删除页面的用户,包括子孙。",
+    "deletion_explanation": "限制用户对选定的单一页面进行垃圾处理。",
+    "complete_deletion_explanation": "限制可以完全删除所选单页的用户。",
+    "recursive_deletion_explanation": "限制用户可以捣毁包括子孙在内的页面。",
+    "recursive_complete_deletion_explanation": "限制可以完全删除页面的用户,包括子孙。",
+    "is_all_group_membership_required_for_page_complete_deletion": "除管理员和页面作者之外的用户必须属于被授予页面访问权限的所有组",
+    "is_all_group_membership_required_for_page_complete_deletion_explanation": "如果页面权限设置为\"仅限特定群体\",则会启用此功能。",
     "inherit": "继承(使用与单页相同的设置)。",
 		"admin_only": "仅管理员",
 		"admin_and_author": "管理员|作者",
@@ -866,12 +868,12 @@
     "return": "返回",
     "clear": "清除",
     "activity_expiration_date": "审计日志的到期日",
-    "activity_expiration_date_explain": "创建的审计日志会在环境变量中设置的从创建时间算起的秒数后自动删除",
+    "activity_expiration_date_explanation": "创建的审计日志会在环境变量中设置的从创建时间算起的秒数后自动删除",
     "fixed_by_env_var": "这是由env var 修复的 <code>{{key}}={{value}}</code>.",
     "available_action_list": "搜索/查看 所有可用的行动",
-    "available_action_list_explain": "在当前配置中可以搜索/查看的行动列表",
+    "available_action_list_explanation": "在当前配置中可以搜索/查看的行动列表",
     "action_list": "行动清单",
-    "disable_mode_explain": "审计日志当前已禁用。 要启用它,请将环境变量 <code>AUDIT_LOG_ENABLED</code> 设置为 true。",
+    "disable_mode_explanation": "审计日志当前已禁用。 要启用它,请将环境变量 <code>AUDIT_LOG_ENABLED</code> 设置为 true。",
     "docs_url": {
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }

+ 6 - 3
apps/app/public/static/locales/zh_CN/translation.json

@@ -318,7 +318,8 @@
 		"already_exists": "具有该路径的页面已存在",
 		"outdated": "页面已被某人更新,现在已过时。",
 		"user_not_admin": "仅管理员用户可以删除",
-    "single_deletion_empty_pages": "空的页面不能被单一删除"
+    "single_deletion_empty_pages": "空的页面不能被单一删除",
+    "complete_deletion_not_allowed_for_user": "您无权永久删除该页面"
   },
   "page_history": {
     "revision_list": "修订清单",
@@ -379,10 +380,12 @@
 			"Current page name": "Current page name",
       "Recursively": "Recursively",
       "Duplicate without exist path": "Duplicate without exist path",
-      "Same page already exists": "Same page already exists"
+      "Same page already exists": "Same page already exists",
+      "Only duplicate user related resources": "Only duplicate user related resources"
     },
     "help": {
-      "recursive": "Duplicate children of under this path recursively"
+      "recursive": "Duplicate children of under this path recursively",
+      "only_user_related_resources": "This will only duplicate pages that the user has permission to view. If the page permission is set to \"Only specific groups\", only user related groups will be set to the page duplicate."
     }
   },
   "duplicated_pages": "{{fromPath}} 已重复",

+ 10 - 0
apps/app/src/client/services/AdminGeneralSecurityContainer.js

@@ -32,6 +32,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       currentPageRecursiveDeletionAuthority: PageRecursiveDeleteConfigValue.Inherit,
       currentPageCompleteDeletionAuthority: PageSingleDeleteCompConfigValue.AdminOnly,
       currentPageRecursiveCompleteDeletionAuthority: PageRecursiveDeleteCompConfigValue.Inherit,
+      isAllGroupMembershipRequiredForPageCompleteDeletion: true,
       previousPageRecursiveDeletionAuthority: null,
       previousPageRecursiveCompleteDeletionAuthority: null,
       expandOtherOptionsForDeletion: false,
@@ -73,6 +74,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       currentPageCompleteDeletionAuthority: generalSetting.pageCompleteDeletionAuthority,
       currentPageRecursiveDeletionAuthority: generalSetting.pageRecursiveDeletionAuthority,
       currentPageRecursiveCompleteDeletionAuthority: generalSetting.pageRecursiveCompleteDeletionAuthority,
+      isAllGroupMembershipRequiredForPageCompleteDeletion: generalSetting.isAllGroupMembershipRequiredForPageCompleteDeletion,
       isShowRestrictedByOwner: !generalSetting.hideRestrictedByOwner,
       isShowRestrictedByGroup: !generalSetting.hideRestrictedByGroup,
       isUsersHomepageDeletionEnabled: generalSetting.isUsersHomepageDeletionEnabled,
@@ -154,6 +156,13 @@ export default class AdminGeneralSecurityContainer extends Container {
     this.setState({ currentPageRecursiveCompleteDeletionAuthority: val });
   }
 
+  /**
+   * Switch isAllGroupMembershipRequiredForPageCompleteDeletion
+   */
+  switchIsAllGroupMembershipRequiredForPageCompleteDeletion() {
+    this.setState({ isAllGroupMembershipRequiredForPageCompleteDeletion: !this.state.isAllGroupMembershipRequiredForPageCompleteDeletion });
+  }
+
   /**
    * Change previousPageRecursiveDeletionAuthority
    */
@@ -225,6 +234,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       pageCompleteDeletionAuthority: this.state.currentPageCompleteDeletionAuthority,
       pageRecursiveDeletionAuthority: this.state.currentPageRecursiveDeletionAuthority,
       pageRecursiveCompleteDeletionAuthority: this.state.currentPageRecursiveCompleteDeletionAuthority,
+      isAllGroupMembershipRequiredForPageCompleteDeletion: this.state.isAllGroupMembershipRequiredForPageCompleteDeletion,
       hideRestrictedByGroup: !this.state.isShowRestrictedByGroup,
       hideRestrictedByOwner: !this.state.isShowRestrictedByOwner,
       isUsersHomepageDeletionEnabled: this.state.isUsersHomepageDeletionEnabled,

+ 1 - 1
apps/app/src/components/Admin/AuditLog/AuditLogDisableMode.tsx

@@ -16,7 +16,7 @@ export const AuditLogDisableMode: FC = () => {
               <h1 className="text-center">{t('audit_log_management.audit_log')}</h1>
               <h3
                 // eslint-disable-next-line react/no-danger
-                dangerouslySetInnerHTML={{ __html: t('audit_log_management.disable_mode_explain') }}
+                dangerouslySetInnerHTML={{ __html: t('audit_log_management.disable_mode_explanation') }}
               />
             </div>
           </div>

+ 2 - 2
apps/app/src/components/Admin/AuditLog/AuditLogSettings.tsx

@@ -21,7 +21,7 @@ export const AuditLogSettings: FC = () => {
     <>
       <h4 className="mt-4">{t('admin:audit_log_management.activity_expiration_date')}</h4>
       <p className="form-text text-muted">
-        {t('admin:audit_log_management.activity_expiration_date_explain')}
+        {t('admin:audit_log_management.activity_expiration_date_explanation')}
       </p>
       <p className="alert alert-warning col-6">
         <span className="material-symbols-outlined">error</span>
@@ -50,7 +50,7 @@ export const AuditLogSettings: FC = () => {
         </a>
       </h4>
       <p className="form-text text-muted">
-        {t('admin:audit_log_management.available_action_list_explain')}
+        {t('admin:audit_log_management.available_action_list_explanation')}
       </p>
       <p className="mt-1">
         <button type="button" className="btn btn-link p-0" aria-expanded="false" onClick={() => setIsExpandActionList(!isExpandActionList)}>

+ 24 - 3
apps/app/src/components/Admin/Security/SecuritySetting.jsx

@@ -238,14 +238,14 @@ class SecuritySetting extends React.Component {
           </button>
         </div>
         <p className="form-text text-muted small">
-          {t(`security_settings.${getDeletionTypeForT(deletionType)}_explain`)}
+          {t(`security_settings.${getDeletionTypeForT(deletionType)}_explanation`)}
         </p>
       </div>
     );
   }
 
   renderPageDeletePermission(currentState, setState, deletionType, isButtonDisabled) {
-    const { t } = this.props;
+    const { t, adminGeneralSecurityContainer } = this.props;
 
     const expantDeleteOptionsState = this.expantDeleteOptionsState(deletionType);
 
@@ -265,7 +265,28 @@ class SecuritySetting extends React.Component {
           {
             !isRecursiveDeletion(deletionType)
               ? (
-                <>{this.renderPageDeletePermissionDropdown(currentState, setState, deletionType, isButtonDisabled)}</>
+                <>
+                  {this.renderPageDeletePermissionDropdown(currentState, setState, deletionType, isButtonDisabled)}
+                  {currentState === PageDeleteConfigValue.Anyone && deletionType === DeletionType.CompleteDeletion && (
+                    <>
+                      <input
+                        id="isAllGroupMembershipRequiredForPageCompleteDeletionCheckbox"
+                        className="form-check-input"
+                        type="checkbox"
+                        checked={adminGeneralSecurityContainer.state.isAllGroupMembershipRequiredForPageCompleteDeletion}
+                        onChange={() => { adminGeneralSecurityContainer.switchIsAllGroupMembershipRequiredForPageCompleteDeletion() }}
+                      />
+                      <label className="form-check-label" htmlFor="isAllGroupMembershipRequiredForPageCompleteDeletionCheckbox">
+                        {t('security_settings.is_all_group_membership_required_for_page_complete_deletion')}
+                      </label>
+                      <p
+                        className="form-text text-muted small mt-2"
+                      >
+                        {t('security_settings.is_all_group_membership_required_for_page_complete_deletion_explanation')}
+                      </p>
+                    </>
+                  )}
+                </>
               )
               : (
                 <>

+ 62 - 30
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,
@@ -31,7 +31,7 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   const [selectedGrant, setSelectedGrant] = useState<PageGrant>(PageGrant.GRANT_RESTRICTED);
 
   const [isGroupSelectModalShown, setIsGroupSelectModalShown] = useState(false);
-  const [selectedGroup, setSelectedGroup] = useState<PopulatedGrantedGroup | undefined>(undefined);
+  const [selectedGroups, setSelectedGroups] = useState<PopulatedGrantedGroup[]>([]);
 
   // Alert message state
   const [shouldShowModalAlert, setShowModalAlert] = useState<boolean>(false);
@@ -42,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: PopulatedGrantedGroup) => {
+    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;
     }
@@ -59,7 +68,9 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
     try {
       await apiv3Put(`/page/${pageId}/grant`, {
         grant: selectedGrant,
-        grantedGroups: selectedGroup?.item._id != null ? [{ item: selectedGroup?.item._id, type: selectedGroup.type }] : null,
+        userRelatedGrantedGroups: selectedGroups.length !== 0 ? selectedGroups.map((g) => {
+          return { item: g.item._id, type: g.type };
+        }) : null,
       });
 
       toastSuccess(t('Successfully updated'));
@@ -88,10 +99,10 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
     }
 
     if (grantData.grant === 5) {
-      if (grantData.grantedGroups == null || grantData.grantedGroups.length === 0) {
+      if (grantData.userRelatedGrantedGroups == null || grantData.userRelatedGrantedGroups.length === 0) {
         return t('fix_page_grant.modal.grant_label.isForbidden');
       }
-      return `${t('fix_page_grant.modal.radio_btn.grant_group')}: (${grantData.grantedGroups[0].name})`;
+      return `${t('fix_page_grant.modal.radio_btn.grant_group')} (${grantData.userRelatedGrantedGroups.map(g => g.name).join(', ')})`;
     }
 
     throw Error('cannot get grant label'); // this error can't be throwed
@@ -180,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-start ms-2">
                       {
-                        selectedGroup == null
+                        selectedGroups.length === 0
                           ? t('fix_page_grant.modal.select_group_default_text')
-                          : selectedGroup.item.name
+                          : selectedGroups.map(g => g.item.name).join(', ')
                       }
                     </span>
                   </button>
-                  <div className="dropdown-menu">
-                    {
-                      applicableGroups != null && applicableGroups.map(g => (
-                        <button
-                          key={g.item._id}
-                          className="dropdown-item"
-                          type="button"
-                          onClick={() => setSelectedGroup(g)}
-                        >
-                          {g.item.name}
-                        </button>
-                      ))
-                    }
-                  </div>
                 </div>
               </div>
               {
@@ -227,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>
+      )}
+    </>
   );
 };
 

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

@@ -14,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) {
@@ -35,7 +39,7 @@ export const PageGrantAlert = (): JSX.Element => {
           <>
             <i className="icon-fw icon-organization"></i>
             <strong>{
-              isPopulated(pageData.grantedGroups[0].item) && pageData.grantedGroups[0].item.name
+              populatedGrantedGroups().map(g => g.item.name).join(', ')
             }
             </strong>
           </>

+ 27 - 9
apps/app/src/components/PageDuplicateModal.tsx

@@ -37,6 +37,7 @@ const PageDuplicateModal = (): JSX.Element => {
   const [existingPaths, setExistingPaths] = useState<string[]>([]);
   const [isDuplicateRecursively, setIsDuplicateRecursively] = useState(true);
   const [isDuplicateRecursivelyWithoutExistPath, setIsDuplicateRecursivelyWithoutExistPath] = useState(true);
+  const [onlyDuplicateUserRelatedResources, setOnlyDuplicateUserRelatedResources] = useState(false);
 
   const updateSubordinatedList = useCallback(async() => {
     if (page == null) {
@@ -114,7 +115,9 @@ const PageDuplicateModal = (): JSX.Element => {
 
     const { pageId, path } = page;
     try {
-      const { data } = await apiv3Post('/pages/duplicate', { pageId, pageNameInput, isRecursively: isDuplicateRecursively });
+      const { data } = await apiv3Post('/pages/duplicate', {
+        pageId, pageNameInput, isRecursively: isDuplicateRecursively, onlyDuplicateUserRelatedResources,
+      });
       const onDuplicated = duplicateModalData?.opts?.onDuplicated;
       const fromPath = path;
       const toPath = data.page.path;
@@ -127,7 +130,7 @@ const PageDuplicateModal = (): JSX.Element => {
     catch (err) {
       setErrs(err);
     }
-  }, [closeDuplicateModal, duplicateModalData?.opts?.onDuplicated, isDuplicateRecursively, page, pageNameInput]);
+  }, [closeDuplicateModal, duplicateModalData?.opts?.onDuplicated, isDuplicateRecursively, page, pageNameInput, onlyDuplicateUserRelatedResources]);
 
   useEffect(() => {
     if (isOpened) {
@@ -193,7 +196,7 @@ const PageDuplicateModal = (): JSX.Element => {
           <p className="text-danger">Error: Target path is duplicated.</p>
         ) }
 
-        <div className="form-check form-check-warning mb-3">
+        <div className="form-check form-check-warning">
           <input
             className="form-check-input"
             name="recursively"
@@ -204,7 +207,7 @@ const PageDuplicateModal = (): JSX.Element => {
           />
           <label className="form-label form-check-label" htmlFor="cbDuplicateRecursively">
             { t('modal_duplicate.label.Recursively') }
-            <p className="form-text text-muted mt-0">{ t('modal_duplicate.help.recursive') }</p>
+            <p className="form-text text-muted my-0">{ t('modal_duplicate.help.recursive') }</p>
           </label>
 
           <div>
@@ -220,15 +223,30 @@ const PageDuplicateModal = (): JSX.Element => {
                 />
                 <label className="form-label form-check-label" htmlFor="cbDuplicatewithoutExistRecursively">
                   { t('modal_duplicate.label.Duplicate without exist path') }
+                  <p className="form-text text-muted my-0">{ t('modal_duplicate.help.recursive') }</p>
                 </label>
               </div>
             )}
           </div>
-          <div>
-            {isDuplicateRecursively && existingPaths.length !== 0 && (
-              <DuplicatePathsTable existingPaths={existingPaths} fromPath={path} toPath={pageNameInput} />
-            ) }
-          </div>
+        </div>
+
+        <div className="form-check form-check-warning mb-3">
+          <input
+            className="form-check-input"
+            id="cbOnlyDuplicateUserRelatedResources"
+            type="checkbox"
+            checked={onlyDuplicateUserRelatedResources}
+            onChange={() => setOnlyDuplicateUserRelatedResources(!onlyDuplicateUserRelatedResources)}
+          />
+          <label className="form-label form-check-label" htmlFor="cbOnlyDuplicateUserRelatedResources">
+            { t('modal_duplicate.label.Only duplicate user related resources') }
+            <p className="form-text text-muted my-0">{ t('modal_duplicate.help.only_user_related_resources') }</p>
+          </label>
+        </div>
+        <div>
+          {isDuplicateRecursively && existingPaths.length !== 0 && (
+            <DuplicatePathsTable existingPaths={existingPaths} fromPath={path} toPath={pageNameInput} />
+          ) }
         </div>
       </>
     );

+ 2 - 2
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -220,7 +220,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     if (grantData == null) {
       return;
     }
-    const grantedGroups = grantData.grantedGroups?.map((group) => {
+    const userRelatedGrantedGroups = grantData.userRelatedGrantedGroups?.map((group) => {
       return { item: group.id, type: group.type };
     });
     const optionsToSave = {
@@ -228,7 +228,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
       slackChannels: '', // set in save method by opts in SavePageControlls.tsx
       grant: grantData.grant,
       // pageTags: pageTags ?? [],
-      grantUserGroupIds: grantedGroups,
+      userRelatedGrantUserGroupIds: userRelatedGrantedGroups,
     };
     return optionsToSave;
   }, [grantData, isSlackEnabled]);

+ 4 - 0
apps/app/src/components/PageManagement/ApiErrorMessage.jsx

@@ -30,6 +30,10 @@ const ApiErrorMessage = (props) => {
         return (
           <strong><i className="icon-fw icon-ban"></i>{ t('page_api_error.user_not_admin') }</strong>
         );
+      case 'complete_deletion_not_allowed_for_user':
+        return (
+          <strong><i className="icon-fw icon-ban"></i>{ t('page_api_error.complete_deletion_not_allowed_for_user') }</strong>
+        );
       case 'outdated':
         return (
           <>

+ 2 - 2
apps/app/src/components/SavePageControls.tsx

@@ -67,7 +67,7 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
     return null;
   }
 
-  const { grant, grantedGroups } = grantData;
+  const { grant, userRelatedGrantedGroups } = grantData;
 
   const isGrantSelectorDisabledPage = isTopPage(currentPage?.path ?? '') || isUsersProtectedPages(currentPage?.path ?? '');
   const labelSubmitButton = t('Update');
@@ -82,7 +82,7 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
             <GrantSelector
               grant={grant}
               disabled={isGrantSelectorDisabledPage}
-              grantedGroups={grantedGroups}
+              userRelatedGrantedGroups={userRelatedGrantedGroups}
               onUpdateGrant={updateGrantHandler}
             />
           </div>

+ 40 - 18
apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx

@@ -34,7 +34,7 @@ const AVAILABLE_GRANTS = [
 type Props = {
   disabled?: boolean,
   grant: number,
-  grantedGroups?: {
+  userRelatedGrantedGroups?: {
     id: string,
     name: string,
     type: GroupType,
@@ -51,7 +51,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
 
   const {
     disabled,
-    grantedGroups,
+    userRelatedGrantedGroups,
     onUpdateGrant,
     grant: currentGrant,
   } = props;
@@ -80,18 +80,23 @@ export const GrantSelector = (props: Props): JSX.Element => {
     }
 
     if (onUpdateGrant != null) {
-      onUpdateGrant({ grant, grantedGroups: undefined });
+      onUpdateGrant({ grant, userRelatedGrantedGroups: undefined });
     }
   }, [onUpdateGrant, showSelectGroupModal]);
 
   const groupListItemClickHandler = useCallback((grantGroup: IGrantedGroup) => {
     if (onUpdateGrant != null && isPopulated(grantGroup.item)) {
-      onUpdateGrant({ grant: 5, grantedGroups: [{ id: grantGroup.item._id, name: grantGroup.item.name, type: grantGroup.type }] });
+      let userRelatedGrantedGroupsCopy = userRelatedGrantedGroups != null ? [...userRelatedGrantedGroups] : [];
+      const grantGroupInfo = { id: grantGroup.item._id, name: grantGroup.item.name, type: grantGroup.type };
+      if (userRelatedGrantedGroupsCopy.find(group => group.id === grantGroupInfo.id) == null) {
+        userRelatedGrantedGroupsCopy.push(grantGroupInfo);
+      }
+      else {
+        userRelatedGrantedGroupsCopy = userRelatedGrantedGroupsCopy.filter(group => group.id !== grantGroupInfo.id);
+      }
+      onUpdateGrant({ grant: 5, userRelatedGrantedGroups: userRelatedGrantedGroupsCopy });
     }
-
-    // hide modal
-    setIsSelectGroupModalShown(false);
-  }, [onUpdateGrant]);
+  }, [onUpdateGrant, userRelatedGrantedGroups]);
 
   /**
    * Render grant selector DOM.
@@ -102,7 +107,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
     let dropdownToggleLabelElm;
 
     const dropdownMenuElems = AVAILABLE_GRANTS.map((opt) => {
-      const label = ((opt.grant === 5 && opt.reselectLabel != null) && grantedGroups != null && grantedGroups.length > 0)
+      const label = ((opt.grant === 5 && opt.reselectLabel != null) && userRelatedGrantedGroups != null && userRelatedGrantedGroups.length > 0)
         ? opt.reselectLabel // when grantGroup is selected
         : opt.label;
 
@@ -123,11 +128,19 @@ export const GrantSelector = (props: Props): JSX.Element => {
     });
 
     // add specified group option
-    if (grantedGroups != null && grantedGroups.length > 0) {
+    if (userRelatedGrantedGroups != null && userRelatedGrantedGroups.length > 0) {
       const labelElm = (
         <span>
           <i className="icon icon-fw icon-organization"></i>
-          <span className="label">{grantedGroups[0].name}</span>
+          <span className="label">
+            {userRelatedGrantedGroups.length > 1
+              ? (
+                <span>
+                  {`${userRelatedGrantedGroups[0].name}... `}
+                  <span className="badge badge-purple">+{userRelatedGrantedGroups.length - 1}</span>
+                </span>
+              ) : userRelatedGrantedGroups[0].name}
+          </span>
         </span>
       );
 
@@ -149,7 +162,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
         </UncontrolledDropdown>
       </div>
     );
-  }, [changeGrantHandler, currentGrant, disabled, grantedGroups, t]);
+  }, [changeGrantHandler, currentGrant, disabled, userRelatedGrantedGroups, t]);
 
   /**
    * Render select grantgroup modal.
@@ -180,20 +193,30 @@ export const GrantSelector = (props: Props): JSX.Element => {
     }
 
     return (
-      <div className="list-group">
+      <>
         { myUserGroups.map((group) => {
+          const groupIsGranted = userRelatedGrantedGroups?.find(g => g.id === group.item._id) != null;
+          const activeClass = groupIsGranted ? 'active' : '';
+
           return (
-            <button key={group.item._id} type="button" className="list-group-item list-group-item-action" onClick={() => groupListItemClickHandler(group)}>
-              <h5 className="d-inline-block">{group.item.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, myUserGroups, shouldFetch, t]);
+  }, [currentUser?.admin, groupListItemClickHandler, myUserGroups, shouldFetch, t, userRelatedGrantedGroups]);
 
   return (
     <>
@@ -202,7 +225,6 @@ export const GrantSelector = (props: Props): JSX.Element => {
       {/* render modal */}
       { !disabled && currentUser != null && (
         <Modal
-          className="select-grant-group"
           isOpen={isSelectGroupModalShown}
           toggle={() => setIsSelectGroupModalShown(false)}
         >

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

@@ -149,7 +149,6 @@ export const SyncExecution = ({
       </form>
 
       <Modal
-        className="select-grant-group"
         isOpen={isAlertModalOpen}
         toggle={() => setIsAlertModalOpen(false)}
       >

+ 1 - 1
apps/app/src/interfaces/page-operation.ts

@@ -33,6 +33,6 @@ export type OptionsToSave = {
   isSlackEnabled: boolean;
   slackChannels: string;
   grant: number;
-  // grantUserGroupIds?: IGrantedGroup[];
+  // userRelatedGrantUserGroupIds?: IGrantedGroup[];
   // isSyncRevisionToHackmd?: boolean;
 };

+ 1 - 1
apps/app/src/interfaces/page.ts

@@ -10,7 +10,7 @@ export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean, processData
 
 export type IPageGrantData = {
   grant: number,
-  grantedGroups?: {
+  userRelatedGrantedGroups?: {
     id: string,
     name: string,
     type: GroupType,

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

@@ -1,6 +1,6 @@
 import loggerFactory from '~/utils/logger';
 
-const logger = loggerFactory('growi:remove-basic-auth-related-config');
+const logger = loggerFactory('growi:granted-group-to-array-of-objects');
 
 module.exports = {
   async up(db, client) {

+ 28 - 0
apps/app/src/migrations/20231223155127-non-null-granted-groups.js

@@ -0,0 +1,28 @@
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:non-null-granted-groups');
+
+module.exports = {
+  async up(db, client) {
+    logger.info('Apply migration');
+
+    const pageCollection = await db.collection('pages');
+
+    await pageCollection.updateMany(
+      { grantedGroups: { $eq: null } },
+      [
+        {
+          $set: {
+            grantedGroups: [],
+          },
+        },
+      ],
+    );
+
+    logger.info('Migration has successfully applied');
+  },
+
+  async down(db, client) {
+    // No rollback
+  },
+};

+ 12 - 7
apps/app/src/pages/[[...path]].page.tsx

@@ -3,8 +3,9 @@ import React, { useEffect } from 'react';
 
 import EventEmitter from 'events';
 
-import { isIPageInfoForEntity } from '@growi/core';
+import { isIPageInfoForEntity, isPopulated } from '@growi/core';
 import type {
+  GroupType,
   IDataWithMeta, IPageInfoForEntity, IPagePopulatedToShowRevision,
 } from '@growi/core';
 import {
@@ -410,7 +411,7 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
 
   const Page = crowi.model('Page') as PageModel;
   const PageRedirect = mongooseModel('PageRedirect') as PageRedirectModel;
-  const { pageService, configManager } = crowi;
+  const { pageService, configManager, pageGrantService } = crowi;
 
   let currentPathname = props.currentPathname;
 
@@ -464,16 +465,20 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
     // apply parent page grant, without groups that user isn't related to
     const ancestor = await Page.findAncestorByPathAndViewer(currentPathname, user);
     if (ancestor != null) {
-      const userRelatedGrantedGroups = await pageService.getUserRelatedGrantedGroups(ancestor, user);
-      props.grantData = {
-        grant: ancestor.grant,
-        grantedGroups: userRelatedGrantedGroups.map((group) => {
+      ancestor.populate('grantedGroups.item');
+      const userRelatedGrantedGroups = (await pageGrantService.getUserRelatedGrantedGroups(ancestor, user)).map((group) => {
+        if (isPopulated(group.item)) {
           return {
             id: group.item._id,
             name: group.item.name,
             type: group.type,
           };
-        }),
+        }
+        return null;
+      }).filter((info): info is NonNullable<{id: string, name: string, type: GroupType}> => info != null);
+      props.grantData = {
+        grant: ancestor.grant,
+        userRelatedGrantedGroups,
       };
     }
   }

+ 4 - 3
apps/app/src/server/crowi/index.js

@@ -715,12 +715,13 @@ Crowi.prototype.setupGrowiPluginService = async function() {
 };
 
 Crowi.prototype.setupPageService = async function() {
-  if (this.pageService == null) {
-    this.pageService = new PageService(this);
-  }
   if (this.pageGrantService == null) {
     this.pageGrantService = new PageGrantService(this);
   }
+  // initialize after pageGrantService since pageService uses pageGrantService in constructor
+  if (this.pageService == null) {
+    this.pageService = new PageService(this);
+  }
   if (this.pageOperationService == null) {
     this.pageOperationService = new PageOperationService(this);
     await this.pageOperationService.init();

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

@@ -70,6 +70,7 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
   'security:pageCompleteDeletionAuthority' : undefined,
   'security:pageRecursiveDeletionAuthority' : undefined,
   'security:pageRecursiveCompleteDeletionAuthority' : undefined,
+  'security:isAllGroupMembershipRequiredForPageCompleteDeletion' : true,
   'security:disableLinkSharing' : false,
   'security:user-homepage-deletion:isEnabled': false,
   'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion': false,

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

@@ -23,7 +23,7 @@ export type IUserForResuming = {
 
 export type IOptionsForUpdate = {
   grant?: PageGrant,
-  grantUserGroupIds?: IGrantedGroup[],
+  userRelatedGrantUserGroupIds?: IGrantedGroup[],
   isSyncRevisionToHackmd?: boolean,
   overwriteScopesOfDescendants?: boolean,
 };

+ 1 - 25
apps/app/src/server/models/obsolete-page.js

@@ -639,30 +639,6 @@ export const getPageSchema = (crowi) => {
     return { templateBody, templateTags };
   };
 
-  pageSchema.statics.applyScopesToDescendantsAsyncronously = async function(parentPage, user, isV4 = false) {
-    const builder = new this.PageQueryBuilder(this.find());
-    builder.addConditionToListOnlyDescendants(parentPage.path);
-
-    if (isV4) {
-      builder.addConditionAsRootOrNotOnTree();
-    }
-    else {
-      builder.addConditionAsOnTree();
-    }
-
-    // add grant conditions
-    await addConditionToFilteringByViewerToEdit(builder, user);
-
-    const grant = parentPage.grant;
-
-    await builder.query.updateMany({}, {
-      grant,
-      grantedGroups: grant === PageGrant.GRANT_USER_GROUP ? parentPage.grantedGroups : null,
-      grantedUsers: grant === PageGrant.GRANT_OWNER ? [user._id] : null,
-    });
-
-  };
-
   pageSchema.statics.findListByPathsArray = async function(paths, includeEmpty = false) {
     const queryBuilder = new this.PageQueryBuilder(this.find(), includeEmpty);
     queryBuilder.addConditionToListByPathsArray(paths);
@@ -676,7 +652,7 @@ export const getPageSchema = (crowi) => {
         updateOne: {
           filter: { _id: page._id },
           update: {
-            grantedGroups: null,
+            grantedGroups: [],
             grant: this.GRANT_PUBLIC,
           },
         },

+ 1 - 0
apps/app/src/server/models/page.ts

@@ -127,6 +127,7 @@ const schema = new Schema<PageDocument, PageModel>({
       return arr.length === uniqueItemValues.size;
     }, 'grantedGroups contains non unique item'],
     default: [],
+    required: true,
   },
   creator: { type: ObjectId, ref: 'User', index: true },
   lastUpdateUser: { type: ObjectId, ref: 'User' },

+ 9 - 1
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -8,6 +8,7 @@ import { query, oneOf } from 'express-validator';
 import mongoose from 'mongoose';
 
 
+import { IPageGrantService } from '~/server/service/page-grant';
 import loggerFactory from '~/utils/logger';
 
 import Crowi from '../../crowi';
@@ -119,6 +120,8 @@ const routerFactory = (crowi: Crowi): Router => {
     const Bookmark = crowi.model('Bookmark');
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     const pageService: PageService = crowi.pageService!;
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const pageGrantService: IPageGrantService = crowi.pageGrantService!;
 
     try {
       const pages = pageIds != null
@@ -140,16 +143,21 @@ const routerFactory = (crowi: Crowi): Router => {
       const idToPageInfoMap: Record<string, IPageInfo | IPageInfoForListing> = {};
 
       const isGuestUser = req.user == null;
+
+      const userRelatedGroups = await pageGrantService.getUserRelatedGroups(req.user);
+
       for (const page of pages) {
         // construct isIPageInfoForListing
         const basicPageInfo = pageService.constructBasicPageInfo(page, isGuestUser);
 
+        const canDeleteCompletely = pageService.canDeleteCompletely(page, req.user, false, userRelatedGroups); // use normal delete config
+
         const pageInfo = (!isIPageInfoForEntity(basicPageInfo))
           ? basicPageInfo
           // create IPageInfoForListing
           : {
             ...basicPageInfo,
-            isAbleToDeleteCompletely: pageService.canDeleteCompletely(page.path, (page.creator as IUserHasId)?._id, req.user, false), // use normal delete config
+            isAbleToDeleteCompletely: canDeleteCompletely,
             bookmarkCount: bookmarkCountMap != null ? bookmarkCountMap[page._id] : undefined,
             revisionShortBody: shortBodiesMap != null ? shortBodiesMap[page._id] : undefined,
           } as IPageInfoForListing;

+ 9 - 7
apps/app/src/server/routes/apiv3/page.js

@@ -478,7 +478,8 @@ module.exports = (crowi) => {
       return res.apiv3Err(err, 500);
     }
 
-    const { grantedUserGroups, grantedExternalUserGroups } = divideByType(grantedGroups);
+    const userRelatedGrantedGroups = await crowi.pageGrantService.getUserRelatedGrantedGroups(page, req.user);
+    const { grantedUserGroups, grantedExternalUserGroups } = divideByType(userRelatedGrantedGroups);
     const currentPageUserGroups = await UserGroup.find({ _id: { $in: grantedUserGroups } });
     const currentPageExternalUserGroups = await ExternalUserGroup.find({ _id: { $in: grantedExternalUserGroups } });
     const grantedUserGroupData = currentPageUserGroups.map((group) => {
@@ -489,7 +490,7 @@ module.exports = (crowi) => {
     });
     const currentPageGrant = {
       grant,
-      grantedGroups: [...grantedUserGroupData, ...grantedExternalUserGroupData],
+      userRelatedGrantedGroups: [...grantedUserGroupData, ...grantedExternalUserGroupData],
     };
 
     // page doesn't have parent page
@@ -514,10 +515,11 @@ module.exports = (crowi) => {
       return res.apiv3({ isGrantNormalized, grantData });
     }
 
+    const userRelatedParentGrantedGroups = await crowi.pageGrantService.getUserRelatedGrantedGroups(parentPage, req.user);
     const {
       grantedUserGroups: parentGrantedUserGroupIds,
       grantedExternalUserGroups: parentGrantedExternalUserGroupIds,
-    } = divideByType(parentPage.grantedGroups);
+    } = divideByType(userRelatedParentGrantedGroups);
     const parentPageUserGroups = await UserGroup.find({ _id: { $in: parentGrantedUserGroupIds } });
     const parentPageExternalUserGroups = await ExternalUserGroup.find({ _id: { $in: parentGrantedExternalUserGroupIds } });
     const parentGrantedUserGroupData = parentPageUserGroups.map((group) => {
@@ -528,7 +530,7 @@ module.exports = (crowi) => {
     });
     const parentPageGrant = {
       grant: parentPage.grant,
-      grantedGroups: [...parentGrantedUserGroupData, ...parentGrantedExternalUserGroupData],
+      userRelatedGrantedGroups: [...parentGrantedUserGroupData, ...parentGrantedExternalUserGroupData],
     };
 
     const grantData = {
@@ -565,10 +567,10 @@ module.exports = (crowi) => {
 
   router.put('/:pageId/grant', loginRequiredStrictly, excludeReadOnlyUser, validator.updateGrant, apiV3FormValidator, async(req, res) => {
     const { pageId } = req.params;
-    const { grant, grantedGroups } = req.body;
+    const { grant, userRelatedGrantedGroups } = req.body;
 
     // TODO: remove in https://redmine.weseek.co.jp/issues/136137
-    if (grantedGroups != null && grantedGroups.length > 1) {
+    if (userRelatedGrantedGroups != null && userRelatedGrantedGroups.length > 1) {
       return res.apiv3Err('Cannot grant multiple groups to page at the moment');
     }
 
@@ -584,7 +586,7 @@ module.exports = (crowi) => {
     let data;
     try {
       const shouldUseV4Process = false;
-      const grantData = { grant, grantedGroups };
+      const grantData = { grant, userRelatedGrantedGroups };
       data = await crowi.pageService.updateGrant(page, req.user, grantData, shouldUseV4Process);
     }
     catch (err) {

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

@@ -802,7 +802,7 @@ module.exports = (crowi) => {
    */
   router.post('/duplicate', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity, validator.duplicatePage, apiV3FormValidator,
     async(req, res) => {
-      const { pageId, isRecursively } = req.body;
+      const { pageId, isRecursively, onlyDuplicateUserRelatedResources } = req.body;
 
       const newPagePath = normalizePath(req.body.pageNameInput);
 
@@ -838,7 +838,7 @@ module.exports = (crowi) => {
         return res.apiv3Err(new ErrorV3(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 401);
       }
 
-      const newParentPage = await crowi.pageService.duplicate(page, newPagePath, req.user, isRecursively);
+      const newParentPage = await crowi.pageService.duplicate(page, newPagePath, req.user, isRecursively, onlyDuplicateUserRelatedResources);
       const result = { page: serializePageSecurely(newParentPage) };
 
       // copy the page since it's used and updated in crowi.pageService.duplicate

+ 5 - 0
apps/app/src/server/routes/apiv3/security-settings/index.js

@@ -357,6 +357,8 @@ module.exports = (crowi) => {
         pageCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority'),
         pageRecursiveDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority'),
         pageRecursiveCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
+        isAllGroupMembershipRequiredForPageCompleteDeletion:
+        await configManager.getConfig('crowi', 'security:isAllGroupMembershipRequiredForPageCompleteDeletion'),
         hideRestrictedByOwner: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
         hideRestrictedByGroup: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
         isUsersHomepageDeletionEnabled: await configManager.getConfig('crowi', 'security:user-homepage-deletion:isEnabled'),
@@ -627,6 +629,7 @@ module.exports = (crowi) => {
       'security:pageRecursiveDeletionAuthority': req.body.pageRecursiveDeletionAuthority,
       'security:pageCompleteDeletionAuthority': req.body.pageCompleteDeletionAuthority,
       'security:pageRecursiveCompleteDeletionAuthority': req.body.pageRecursiveCompleteDeletionAuthority,
+      'security:isAllGroupMembershipRequiredForPageCompleteDeletion': req.body.isAllGroupMembershipRequiredForPageCompleteDeletion,
       'security:list-policy:hideRestrictedByOwner': req.body.hideRestrictedByOwner,
       'security:list-policy:hideRestrictedByGroup': req.body.hideRestrictedByGroup,
       'security:user-homepage-deletion:isEnabled': req.body.isUsersHomepageDeletionEnabled,
@@ -660,6 +663,8 @@ module.exports = (crowi) => {
         pageCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority'),
         pageRecursiveDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority'),
         pageRecursiveCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
+        isAllGroupMembershipRequiredForPageCompleteDeletion:
+        await configManager.getConfig('crowi', 'security:isAllGroupMembershipRequiredForPageCompleteDeletion'),
         hideRestrictedByOwner: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
         hideRestrictedByGroup: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
         isUsersHomepageDeletionEnabled: await configManager.getConfig('crowi', 'security:user-homepage-deletion:isEnabled'),

+ 6 - 9
apps/app/src/server/routes/page.js

@@ -449,16 +449,11 @@ 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 grantUserGroupIds = req.body.grantUserGroupIds || null;
+    const userRelatedGrantUserGroupIds = req.body.userRelatedGrantUserGroupIds || null;
     const overwriteScopesOfDescendants = req.body.overwriteScopesOfDescendants || null;
     const isSlackEnabled = !!req.body.isSlackEnabled; // cast to boolean
     const slackChannels = req.body.slackChannels || null;
 
-    // TODO: remove in https://redmine.weseek.co.jp/issues/136140
-    if (grantUserGroupIds != null && grantUserGroupIds.length > 1) {
-      return res.apiv3Err('Cannot grant multiple groups to page at the moment');
-    }
-
     if (pageId === null || pageBody === null || revisionId === null) {
       return res.json(ApiResponse.error('page_id, body and revision_id are required.'));
     }
@@ -486,7 +481,7 @@ module.exports = function(crowi, app) {
     const options = { overwriteScopesOfDescendants };
     if (grant != null) {
       options.grant = grant;
-      options.grantUserGroupIds = grantUserGroupIds;
+      options.userRelatedGrantUserGroupIds = userRelatedGrantUserGroupIds;
     }
 
     const previousRevision = await Revision.findById(revisionId);
@@ -758,8 +753,10 @@ module.exports = function(crowi, app) {
 
     try {
       if (isCompletely) {
-        if (!crowi.pageService.canDeleteCompletely(page.path, creator, req.user, isRecursively)) {
-          return res.json(ApiResponse.error('You can not delete this page completely', 'user_not_admin'));
+        const userRelatedGroups = await crowi.pageGrantService.getUserRelatedGroups(req.user);
+        const canDeleteCompletely = crowi.pageService.canDeleteCompletely(page, req.user, isRecursively, userRelatedGroups);
+        if (!canDeleteCompletely) {
+          return res.json(ApiResponse.error('You cannot delete this page completely', 'complete_deletion_not_allowed_for_user'));
         }
 
         if (pagePathUtils.isUsersHomepage(page.path)) {

+ 147 - 16
apps/app/src/server/service/page-grant.ts

@@ -1,20 +1,19 @@
 import {
   type IGrantedGroup,
-  PageGrant, type PageGrantCanBeOnTree, GroupType,
+  PageGrant, GroupType, getIdForRef, isPopulated,
 } 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 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 { IRecordApplicableGrant } from '~/interfaces/page-grant';
+import { IRecordApplicableGrant, PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import { PageDocument, PageModel } from '~/server/models/page';
-import UserGroup, { UserGroupDocument } from '~/server/models/user-group';
-import { includesObjectIds, excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
+import UserGroup from '~/server/models/user-group';
+import { includesObjectIds, excludeTestIdsFromTargetIds, hasIntersection } from '~/server/util/compare-objectId';
 
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import UserGroupRelation from '../models/user-group-relation';
@@ -26,7 +25,7 @@ const { isTopPage } = pagePathUtils;
 const LIMIT_FOR_MULTIPLE_PAGE_OP = 20;
 
 type ComparableTarget = {
-  grant: number,
+  grant?: number,
   grantedUserIds?: ObjectIdLike[],
   grantedGroupIds?: IGrantedGroup[],
   applicableUserIds?: ObjectIdLike[],
@@ -78,7 +77,33 @@ type OperatorGrantInfo = {
   userGroupIds: Set<ObjectIdLike>,
 };
 
-class PageGrantService {
+export interface IPageGrantService {
+  isGrantNormalized: (
+    user,
+    targetPath: string,
+    grant?: PageGrant,
+    grantedUserIds?: ObjectIdLike[],
+    grantedGroupIds?: IGrantedGroup[],
+    shouldCheckDescendants?: boolean,
+    includeNotMigratedPages?: boolean,
+    previousGrantedGroupIds?: IGrantedGroup[]
+  ) => Promise<boolean>,
+  separateNormalizableAndNotNormalizablePages: (user, pages) => Promise<[(PageDocument & { _id: any })[], (PageDocument & { _id: any })[]]>,
+  generateUpdateGrantInfoToOverwriteDescendants: (
+    operator, updateGrant?: PageGrant, grantGroupIds?: IGrantedGroup[],
+  ) => Promise<UpdateGrantInfo>,
+  canOverwriteDescendants: (targetPath: string, operator: { _id: ObjectIdLike }, updateGrantInfo: UpdateGrantInfo) => Promise<boolean>,
+  validateGrantChange: (user, previousGrantedGroupIds: IGrantedGroup[], grant?: PageGrant, grantedGroupIds?: IGrantedGroup[]) => Promise<boolean>,
+  validateGrantChangeSyncronously:(
+    userRelatedGroups: PopulatedGrantedGroup[], previousGrantedGroups: IGrantedGroup[], grant?: PageGrant, grantedGroups?: IGrantedGroup[],
+  ) => boolean,
+  getUserRelatedGroups: (user) => Promise<PopulatedGrantedGroup[]>,
+  getUserRelatedGrantedGroups: (page: PageDocument, user) => Promise<IGrantedGroup[]>,
+  getUserRelatedGrantedGroupsSyncronously: (userRelatedGroups: PopulatedGrantedGroup[], page: PageDocument) => IGrantedGroup[],
+  isUserGrantedPageAccess: (page: PageDocument, user, userRelatedGroups: PopulatedGrantedGroup[]) => boolean
+}
+
+class PageGrantService implements IPageGrantService {
 
   crowi!: any;
 
@@ -103,7 +128,10 @@ class PageGrantService {
    * About the rule of validation, see: https://dev.growi.org/61b2cdabaa330ce7d8152844
    * @returns boolean
    */
-  private processValidation(target: ComparableTarget, ancestor: ComparableAncestor, descendants?: ComparableDescendants): boolean {
+  private validateGrant(target: ComparableTarget, ancestor: ComparableAncestor, descendants?: ComparableDescendants): boolean {
+    /*
+     * the page itself
+     */
     this.validateComparableTarget(target);
 
     const Page = mongoose.model('Page') as unknown as PageModel;
@@ -209,12 +237,55 @@ class PageGrantService {
     return true;
   }
 
+  /**
+   * Validate if page grant can be changed from prior grant to specified grant.
+   * Necessary for pages with multiple group grant.
+   * see: https://dev.growi.org/656745fa52eafe1cf1879508#%E3%83%9A%E3%83%BC%E3%82%B8%E3%81%AE-grant-%E3%81%AE%E6%9B%B4%E6%96%B0
+   * @param user The user who is changing the grant
+   * @param previousGrantedGroups The groups that were granted priorly
+   * @param grant The grant to be changed to
+   * @param grantedGroups The groups to be granted
+   */
+  async validateGrantChange(user, previousGrantedGroups: IGrantedGroup[], grant?: PageGrant, grantedGroups?: IGrantedGroup[]): Promise<boolean> {
+    const userRelatedGroups = await this.getUserRelatedGroups(user);
+    return this.validateGrantChangeSyncronously(userRelatedGroups, previousGrantedGroups, grant, grantedGroups);
+  }
+
+  /**
+   * Use when you do not want to use validateGrantChange with async/await (e.g inside loops that process a large amount of pages)
+   * Specification of userRelatedGroups is necessary to avoid the cost of fetching userRelatedGroups from DB every time.
+   */
+  validateGrantChangeSyncronously(
+      userRelatedGroups: PopulatedGrantedGroup[],
+      previousGrantedGroups: IGrantedGroup[],
+      grant?: PageGrant,
+      grantedGroups?: IGrantedGroup[],
+  ): boolean {
+    const userRelatedGroupIds = userRelatedGroups.map(g => g.item._id);
+    const userBelongsToAllPreviousGrantedGroups = excludeTestIdsFromTargetIds(
+      previousGrantedGroups.map(g => getIdForRef(g.item)),
+      userRelatedGroupIds,
+    ).length === 0;
+
+    if (!userBelongsToAllPreviousGrantedGroups) {
+      if (grant !== PageGrant.GRANT_USER_GROUP) {
+        return false;
+      }
+      const pageGrantIncludesUserRelatedGroup = hasIntersection(grantedGroups?.map(g => getIdForRef(g.item)) || [], userRelatedGroupIds);
+      if (!pageGrantIncludesUserRelatedGroup) {
+        return false;
+      }
+    }
+
+    return true;
+  }
+
   /**
    * Prepare ComparableTarget
    * @returns Promise<ComparableAncestor>
    */
   private async generateComparableTarget(
-      grant, grantedUserIds: ObjectIdLike[] | undefined, grantedGroupIds: IGrantedGroup[] | undefined, includeApplicable: boolean,
+      grant: PageGrant | undefined, grantedUserIds: ObjectIdLike[] | undefined, grantedGroupIds: IGrantedGroup[] | undefined, includeApplicable: boolean,
   ): Promise<ComparableTarget> {
     if (includeApplicable) {
       const Page = mongoose.model('Page') as unknown as PageModel;
@@ -415,27 +486,51 @@ class PageGrantService {
    * Only v5 schema pages will be used to compare by default (Set includeNotMigratedPages to true to include v4 schema pages as well).
    * When comparing, it will use path regex to collect pages instead of using parent attribute of the Page model. This is reasonable since
    * using the path attribute is safer than using the parent attribute in this case. 2022.02.13 -- Taichi Masuyama
+   * @param user The user responsible for execution
+   * @param targetPath Path of page which grant will be validated
+   * @param grant Type of the grant to be validated
+   * @param grantedUserIds Users of grant to be validated
+   * @param grantedGroupIds Groups of grant to be validated
+   * @param shouldCheckDescendants Whether or not to use descendant grant for validation
+   * @param includeNotMigratedPages Whether or not to use unmigrated pages for validation
+   * @param previousGrantedGroupIds
+   *   Previously granted groups of the page. Specific validation is required when previous grant is multiple group grant.
+   *   Apply when page grant change needs to be validated.
+   *   see: https://dev.growi.org/656745fa52eafe1cf1879508#%E3%83%9A%E3%83%BC%E3%82%B8%E3%81%AE-grant-%E3%81%AE%E6%9B%B4%E6%96%B0
    * @returns Promise<boolean>
    */
   async isGrantNormalized(
-      // eslint-disable-next-line max-len
-      user, targetPath: string, grant, grantedUserIds?: ObjectIdLike[], grantedGroupIds?: IGrantedGroup[], shouldCheckDescendants = false, includeNotMigratedPages = false,
+      user,
+      targetPath: string,
+      grant?: PageGrant,
+      grantedUserIds?: ObjectIdLike[],
+      grantedGroupIds?: IGrantedGroup[],
+      shouldCheckDescendants = false,
+      includeNotMigratedPages = false,
+      previousGrantedGroupIds?: IGrantedGroup[],
   ): Promise<boolean> {
     if (isTopPage(targetPath)) {
       return true;
     }
 
+    if (previousGrantedGroupIds != null) {
+      const isGrantChangeable = await this.validateGrantChange(user, previousGrantedGroupIds, grant, grantedGroupIds);
+      if (!isGrantChangeable) {
+        return false;
+      }
+    }
+
     const comparableAncestor = await this.generateComparableAncestor(targetPath, includeNotMigratedPages);
 
     if (!shouldCheckDescendants) { // checking the parent is enough
       const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupIds, false);
-      return this.processValidation(comparableTarget, comparableAncestor);
+      return this.validateGrant(comparableTarget, comparableAncestor);
     }
 
     const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupIds, true);
     const comparableDescendants = await this.generateComparableDescendants(targetPath, user, includeNotMigratedPages);
 
-    return this.processValidation(comparableTarget, comparableAncestor, comparableDescendants);
+    return this.validateGrant(comparableTarget, comparableAncestor, comparableDescendants);
   }
 
   /**
@@ -568,7 +663,10 @@ class PageGrantService {
     return data;
   }
 
-  async getUserRelatedGroups(user) {
+  /*
+   * get all groups that user is related to
+   */
+  async getUserRelatedGroups(user): Promise<PopulatedGrantedGroup[]> {
     const userRelatedUserGroups = await UserGroupRelation.findAllGroupsForUser(user);
     const userRelatedExternalUserGroups = await ExternalUserGroupRelation.findAllGroupsForUser(user);
     return [
@@ -581,6 +679,38 @@ class PageGrantService {
     ];
   }
 
+  /*
+   * get all groups of Page that user is related to
+   */
+  async getUserRelatedGrantedGroups(page: PageDocument, user): Promise<IGrantedGroup[]> {
+    const userRelatedGroups = (await this.getUserRelatedGroups(user));
+    return this.getUserRelatedGrantedGroupsSyncronously(userRelatedGroups, page);
+  }
+
+  /**
+   * Use when you do not want to use getUserRelatedGrantedGroups with async/await (e.g inside loops that process a large amount of pages)
+   * Specification of userRelatedGroups is necessary to avoid the cost of fetching userRelatedGroups from DB every time.
+   */
+  getUserRelatedGrantedGroupsSyncronously(userRelatedGroups: PopulatedGrantedGroup[], page: PageDocument): IGrantedGroup[] {
+    const userRelatedGroupIds: string[] = userRelatedGroups.map(ug => ug.item._id.toString());
+    return page.grantedGroups?.filter((group) => {
+      if (isPopulated(group.item)) {
+        return userRelatedGroupIds.includes(group.item._id.toString());
+      }
+      return userRelatedGroupIds.includes(group.item);
+    }) || [];
+  }
+
+  /**
+   * Check if user is granted access to page
+   */
+  isUserGrantedPageAccess(page: PageDocument, user, userRelatedGroups: PopulatedGrantedGroup[]): boolean {
+    if (page.grant === PageGrant.GRANT_PUBLIC) return true;
+    if (page.grant === PageGrant.GRANT_OWNER) return page.grantedUsers?.includes(user._id.toString()) ?? false;
+    if (page.grant === PageGrant.GRANT_USER_GROUP) return this.getUserRelatedGrantedGroupsSyncronously(userRelatedGroups, page).length > 0;
+    return false;
+  }
+
   /**
    * see: https://dev.growi.org/635a314eac6bcd85cbf359fc
    * @param {string} targetPath
@@ -622,7 +752,7 @@ class PageGrantService {
   }
 
   async generateUpdateGrantInfoToOverwriteDescendants(
-      operator, updateGrant: PageGrantCanBeOnTree, grantGroupIds?: IGrantedGroup[],
+      operator, updateGrant?: PageGrant, grantGroupIds?: IGrantedGroup[],
   ): Promise<UpdateGrantInfo> {
     let updateGrantInfo: UpdateGrantInfo | null = null;
 
@@ -666,6 +796,7 @@ class PageGrantService {
     }
 
     if (updateGrantInfo == null) {
+      // Neither pages with grant `GRANT_RESTRICTED` nor `GRANT_SPECIFIED` can be on a page tree.
       throw Error('The parameter `updateGrant` must be 1, 4, or 5');
     }
 

+ 244 - 88
apps/app/src/server/service/page/index.ts

@@ -4,9 +4,11 @@ import { Readable, Writable } from 'stream';
 
 import type {
   Ref, HasObjectId, IUserHasId, IUser,
-  IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta, IGrantedGroup,
+  IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta, IGrantedGroup, IRevisionHasId,
+} from '@growi/core';
+import {
+  PageGrant, PageStatus, getIdForRef,
 } from '@growi/core';
-import { PageGrant, PageStatus } from '@growi/core';
 import {
   pagePathUtils, pathUtils,
 } from '@growi/core/dist/utils';
@@ -19,7 +21,7 @@ import ExternalUserGroupRelation from '~/features/external-user-group/server/mod
 import { SupportedAction } from '~/interfaces/activity';
 import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import {
-  PageDeleteConfigValue, IPageDeleteConfigValueToProcessValidation,
+  PageDeleteConfigValue, IPageDeleteConfigValueToProcessValidation, PageSingleDeleteCompConfigValue,
 } from '~/interfaces/page-delete-config';
 import { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import {
@@ -46,6 +48,7 @@ import UserGroupRelation from '../../models/user-group-relation';
 import { V5ConversionError } from '../../models/vo/v5-conversion-error';
 import { divideByType } from '../../util/granted-group';
 import { configManager } from '../config-manager';
+import { IPageGrantService } from '../page-grant';
 import { preNotifyService } from '../pre-notify';
 
 import { BULK_REINDEX_SIZE, LIMIT_FOR_MULTIPLE_PAGE_OP } from './consts';
@@ -161,11 +164,14 @@ class PageService implements IPageService {
 
   activityEvent: any;
 
+  pageGrantService: IPageGrantService;
+
   constructor(crowi) {
     this.crowi = crowi;
     this.pageEvent = crowi.event('page');
     this.tagEvent = crowi.event('tag');
     this.activityEvent = crowi.event('activity');
+    this.pageGrantService = crowi.pageGrantService;
 
     // init
     this.initPageEvent();
@@ -186,26 +192,66 @@ class PageService implements IPageService {
     return this.pageEvent;
   }
 
-  canDeleteCompletely(path: string, creatorId: ObjectIdLike, operator: any | null, isRecursively: boolean): boolean {
-    if (operator == null || isTopPage(path) || isUsersTopPage(path)) return false;
+  /**
+   * Check if page can be deleted completely.
+   * Use pageGrantService.getUserRelatedGroups before execution of canDeleteCompletely to get value for userRelatedGroups.
+   * Do NOT use getUserRelatedGrantedGroups inside this method, because canDeleteCompletely should not be async as for now.
+   * The reason for this is because canDeleteCompletely is called in /page-listing/info in a for loop,
+   * and /page-listing/info should not be an execution heavy API.
+   */
+  canDeleteCompletely(
+      page: PageDocument,
+      operator: any | null,
+      isRecursively: boolean,
+      userRelatedGroups: PopulatedGrantedGroup[],
+  ): boolean {
+    if (operator == null || isTopPage(page.path) || isUsersTopPage(page.path)) return false;
 
     const pageCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
     const pageRecursiveCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority');
 
+    if (!this.canDeleteCompletelyAsMultiGroupGrantedPage(page, operator, userRelatedGroups)) return false;
+
     const [singleAuthority, recursiveAuthority] = prepareDeleteConfigValuesForCalc(pageCompleteDeletionAuthority, pageRecursiveCompleteDeletionAuthority);
 
-    return this.canDeleteLogic(creatorId, operator, isRecursively, singleAuthority, recursiveAuthority);
+    return this.canDeleteLogic(page.creator, operator, isRecursively, singleAuthority, recursiveAuthority);
+  }
+
+  /**
+   * If page is multi-group granted, check if operator is allowed to completely delete the page.
+   * see: https://dev.growi.org/656745fa52eafe1cf1879508#%E5%AE%8C%E5%85%A8%E3%81%AB%E5%89%8A%E9%99%A4%E3%81%99%E3%82%8B%E6%93%8D%E4%BD%9C
+   */
+  canDeleteCompletelyAsMultiGroupGrantedPage(page: PageDocument, operator: any | null, userRelatedGroups: PopulatedGrantedGroup[]): boolean {
+    const pageCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
+    const isAllGroupMembershipRequiredForPageCompleteDeletion = this.crowi.configManager.getConfig(
+      'crowi', 'security:isAllGroupMembershipRequiredForPageCompleteDeletion',
+    );
+
+    const isAdmin = operator?.admin ?? false;
+    const isAuthor = operator?._id == null ? false : operator._id.equals(page.creator);
+    const isAdminOrAuthor = isAdmin || isAuthor;
+
+    if (page.grant === PageGrant.GRANT_USER_GROUP
+      && !isAdminOrAuthor && pageCompleteDeletionAuthority === PageSingleDeleteCompConfigValue.Anyone
+      && isAllGroupMembershipRequiredForPageCompleteDeletion) {
+      const userRelatedGrantedGroups = this.pageGrantService.getUserRelatedGrantedGroupsSyncronously(userRelatedGroups, page);
+      if (userRelatedGrantedGroups.length !== page.grantedGroups.length) {
+        return false;
+      }
+    }
+
+    return true;
   }
 
-  canDelete(path: string, creatorId: ObjectIdLike, operator: any | null, isRecursively: boolean): boolean {
-    if (operator == null || isTopPage(path) || isUsersTopPage(path)) return false;
+  canDelete(page: PageDocument, operator: any | null, isRecursively: boolean): boolean {
+    if (operator == null || isTopPage(page.path) || isUsersTopPage(page.path)) return false;
 
     const pageDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageDeletionAuthority');
     const pageRecursiveDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority');
 
     const [singleAuthority, recursiveAuthority] = prepareDeleteConfigValuesForCalc(pageDeletionAuthority, pageRecursiveDeletionAuthority);
 
-    return this.canDeleteLogic(creatorId, operator, isRecursively, singleAuthority, recursiveAuthority);
+    return this.canDeleteLogic(page.creator, operator, isRecursively, singleAuthority, recursiveAuthority);
   }
 
   canDeleteUserHomepageByConfig(): boolean {
@@ -230,16 +276,16 @@ class PageService implements IPageService {
       recursiveAuthority: IPageDeleteConfigValueToProcessValidation | null,
   ): boolean {
     const isAdmin = operator?.admin ?? false;
-    const isOperator = operator?._id == null ? false : operator._id.equals(creatorId);
+    const isAuthor = operator?._id == null ? false : operator._id.equals(creatorId);
 
     if (isRecursively) {
-      return this.compareDeleteConfig(isAdmin, isOperator, recursiveAuthority);
+      return this.compareDeleteConfig(isAdmin, isAuthor, recursiveAuthority);
     }
 
-    return this.compareDeleteConfig(isAdmin, isOperator, authority);
+    return this.compareDeleteConfig(isAdmin, isAuthor, authority);
   }
 
-  private compareDeleteConfig(isAdmin: boolean, isOperator: boolean, authority: IPageDeleteConfigValueToProcessValidation | null): boolean {
+  private compareDeleteConfig(isAdmin: boolean, isAuthor: boolean, authority: IPageDeleteConfigValueToProcessValidation | null): boolean {
     if (isAdmin) {
       return true;
     }
@@ -247,7 +293,7 @@ class PageService implements IPageService {
     if (authority === PageDeleteConfigValue.Anyone || authority == null) {
       return true;
     }
-    if (authority === PageDeleteConfigValue.AdminAndAuthor && isOperator) {
+    if (authority === PageDeleteConfigValue.AdminAndAuthor && isAuthor) {
       return true;
     }
 
@@ -277,9 +323,14 @@ class PageService implements IPageService {
       pages: PageDocument[],
       user: IUserHasId,
       isRecursively: boolean,
-      canDeleteFunction: (path: string, creatorId: ObjectIdLike, operator: any, isRecursively: boolean) => boolean,
+      canDeleteFunction: (page: PageDocument, operator: any, isRecursively: boolean, userRelatedGroups: PopulatedGrantedGroup[]) => boolean,
   ): Promise<PageDocument[]> {
-    const filteredPages = pages.filter(p => p.isEmpty || canDeleteFunction(p.path, p.creator, user, isRecursively));
+    const userRelatedGroups = await this.pageGrantService.getUserRelatedGroups(user);
+    const filteredPages = pages.filter(async(p) => {
+      if (p.isEmpty) return true;
+      const canDelete = canDeleteFunction(p, user, isRecursively, userRelatedGroups);
+      return canDelete;
+    });
 
     if (!this.canDeleteUserHomepageByConfig()) {
       return filteredPages.filter(p => !isUsersHomepage(p.path));
@@ -371,8 +422,11 @@ class PageService implements IPageService {
       const notEmptyClosestAncestor = await Page.findNonEmptyClosestAncestor(page.path);
       creatorId = notEmptyClosestAncestor.creator;
     }
-    const isDeletable = this.canDelete(page.path, creatorId, user, false);
-    const isAbleToDeleteCompletely = this.canDeleteCompletely(page.path, creatorId, user, false); // use normal delete config
+
+    const userRelatedGroups = await this.pageGrantService.getUserRelatedGroups(user);
+
+    const isDeletable = this.canDelete(page, user, false);
+    const isAbleToDeleteCompletely = this.canDeleteCompletely(page, user, false, userRelatedGroups); // use normal delete config
 
     return {
       data: page,
@@ -544,7 +598,7 @@ class PageService implements IPageService {
     if (grant !== Page.GRANT_RESTRICTED) {
       let isGrantNormalized = false;
       try {
-        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, newPagePath, grant, grantedUserIds, grantedGroupIds, false);
+        isGrantNormalized = await this.pageGrantService.isGrantNormalized(user, newPagePath, grant, grantedUserIds, grantedGroupIds, false);
       }
       catch (err) {
         logger.error(`Failed to validate grant of page at "${newPagePath}" when renaming`, err);
@@ -1002,7 +1056,7 @@ class PageService implements IPageService {
   /*
    * Duplicate
    */
-  async duplicate(page, newPagePath, user, isRecursively) {
+  async duplicate(page: PageDocument, newPagePath: string, user, isRecursively: boolean, onlyDuplicateUserRelatedResources: boolean) {
     /*
      * Common Operation
      */
@@ -1023,7 +1077,7 @@ class PageService implements IPageService {
     // 1. Separate v4 & v5 process
     const isShouldUseV4Process = shouldUseV4Process(page);
     if (isShouldUseV4Process) {
-      return this.duplicateV4(page, newPagePath, user, isRecursively);
+      return this.duplicateV4(page, newPagePath, user, isRecursively, onlyDuplicateUserRelatedResources);
     }
 
     const canOperate = await this.crowi.pageOperationService.canOperate(isRecursively, page.path, newPagePath);
@@ -1033,9 +1087,10 @@ class PageService implements IPageService {
 
     // 2. UserGroup & Owner validation
     // use the parent's grant when target page is an empty page
-    let grant;
+    let grant: PageGrant;
     let grantedUserIds;
-    let grantedGroupIds;
+    let grantedGroupIds: IGrantedGroup[];
+
     if (page.isEmpty) {
       const parent = await Page.findOne({ _id: page.parent });
       if (parent == null) {
@@ -1043,18 +1098,18 @@ class PageService implements IPageService {
       }
       grant = parent.grant;
       grantedUserIds = parent.grantedUsers;
-      grantedGroupIds = parent.grantedGroups;
+      grantedGroupIds = onlyDuplicateUserRelatedResources ? (await this.pageGrantService.getUserRelatedGrantedGroups(parent, user)) : parent.grantedGroups;
     }
     else {
       grant = page.grant;
       grantedUserIds = page.grantedUsers;
-      grantedGroupIds = page.grantedGroups;
+      grantedGroupIds = onlyDuplicateUserRelatedResources ? (await this.pageGrantService.getUserRelatedGrantedGroups(page, user)) : page.grantedGroups;
     }
 
     if (grant !== Page.GRANT_RESTRICTED) {
       let isGrantNormalized = false;
       try {
-        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, newPagePath, grant, grantedUserIds, grantedGroupIds, false);
+        isGrantNormalized = await this.pageGrantService.isGrantNormalized(user, newPagePath, grant, grantedUserIds, grantedGroupIds, false);
       }
       catch (err) {
         logger.error(`Failed to validate grant of page at "${newPagePath}" when duplicating`, err);
@@ -1070,8 +1125,8 @@ class PageService implements IPageService {
 
     // 3. Duplicate target
     const options: PageCreateOptions = {
-      grant: page.grant,
-      grantUserGroupIds: page.grantedGroups,
+      grant,
+      grantUserGroupIds: grantedGroupIds,
     };
     let duplicatedTarget;
     if (page.isEmpty) {
@@ -1079,9 +1134,9 @@ class PageService implements IPageService {
       duplicatedTarget = await Page.createEmptyPage(newPagePath, parent);
     }
     else {
-      await page.populate({ path: 'revision', model: 'Revision', select: 'body' });
+      const populatedPage = await page.populate<{revision: IRevisionHasId | null}>({ path: 'revision', model: 'Revision', select: 'body' });
       duplicatedTarget = await (this.create as CreateMethod)(
-        newPagePath, page.revision.body, user, options,
+        newPagePath, populatedPage?.revision?.body ?? '', user, options,
       );
     }
     this.pageEvent.emit('duplicate', page, user);
@@ -1117,7 +1172,7 @@ class PageService implements IPageService {
 
       (async() => {
         try {
-          await this.duplicateRecursivelyMainOperation(page, newPagePath, user, pageOp._id);
+          await this.duplicateRecursivelyMainOperation(page, newPagePath, user, pageOp._id, onlyDuplicateUserRelatedResources);
         }
         catch (err) {
           logger.error('Error occurred while running duplicateRecursivelyMainOperation.', err);
@@ -1135,8 +1190,14 @@ class PageService implements IPageService {
     return result;
   }
 
-  async duplicateRecursivelyMainOperation(page, newPagePath: string, user, pageOpId: ObjectIdLike): Promise<void> {
-    const nDuplicatedPages = await this.duplicateDescendantsWithStream(page, newPagePath, user, false);
+  async duplicateRecursivelyMainOperation(
+      page: PageDocument,
+      newPagePath: string,
+      user,
+      pageOpId: ObjectIdLike,
+      onlyDuplicateUserRelatedResources: boolean,
+  ): Promise<void> {
+    const nDuplicatedPages = await this.duplicateDescendantsWithStream(page, newPagePath, user, onlyDuplicateUserRelatedResources, false);
 
     // normalize parent of descendant pages
     const shouldNormalize = this.shouldNormalizeParent(page);
@@ -1175,7 +1236,7 @@ class PageService implements IPageService {
     await PageOperation.findByIdAndDelete(pageOpId);
   }
 
-  async duplicateV4(page, newPagePath, user, isRecursively) {
+  async duplicateV4(page, newPagePath, user, isRecursively, onlyDuplicateUserRelatedResources: boolean) {
     const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
     // populate
     await page.populate({ path: 'revision', model: 'Revision', select: 'body' });
@@ -1188,13 +1249,13 @@ class PageService implements IPageService {
 
     newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
 
-    const createdPage = await this.crowi.pageService.create(
+    const createdPage = await this.create(
       newPagePath, page.revision.body, user, options,
     );
     this.pageEvent.emit('duplicate', page, user);
 
     if (isRecursively) {
-      this.duplicateDescendantsWithStream(page, newPagePath, user);
+      this.duplicateDescendantsWithStream(page, newPagePath, user, onlyDuplicateUserRelatedResources);
     }
 
     // take over tags
@@ -1248,7 +1309,10 @@ class PageService implements IPageService {
     return PageTagRelation.insertMany(newPageTagRelation, { ordered: false });
   }
 
-  private async duplicateDescendants(pages, user, oldPagePathPrefix, newPagePathPrefix, shouldUseV4Process = true) {
+  private async duplicateDescendants(
+      pages, user, oldPagePathPrefix, newPagePathPrefix,
+      onlyDuplicateUserRelatedResources: boolean, shouldUseV4Process = true,
+  ) {
     if (shouldUseV4Process) {
       return this.duplicateDescendantsV4(pages, user, oldPagePathPrefix, newPagePathPrefix);
     }
@@ -1270,6 +1334,8 @@ class PageService implements IPageService {
     const newPages: any[] = [];
     const newRevisions: any[] = [];
 
+    const userRelatedGroups = await this.pageGrantService.getUserRelatedGroups(user);
+
     // no need to save parent here
     pages.forEach((page) => {
       const newPageId = new mongoose.Types.ObjectId();
@@ -1277,14 +1343,20 @@ class PageService implements IPageService {
       const revisionId = new mongoose.Types.ObjectId();
       pageIdMapping[page._id] = newPageId;
 
+      const isDuplicateTarget = !page.isEmpty
+      && (!onlyDuplicateUserRelatedResources || this.pageGrantService.isUserGrantedPageAccess(page, user, userRelatedGroups));
+
       let newPage;
-      if (!page.isEmpty) {
+      if (isDuplicateTarget) {
+        const grantedGroups = onlyDuplicateUserRelatedResources
+          ? this.pageGrantService.getUserRelatedGrantedGroupsSyncronously(userRelatedGroups, page)
+          : page.grantedGroups;
         newPage = {
           _id: newPageId,
           path: newPagePath,
           creator: user._id,
           grant: page.grant,
-          grantedGroups: page.grantedGroups,
+          grantedGroups,
           grantedUsers: page.grantedUsers,
           lastUpdateUser: user._id,
           revision: revisionId,
@@ -1347,9 +1419,9 @@ class PageService implements IPageService {
     await this.duplicateTags(pageIdMapping);
   }
 
-  private async duplicateDescendantsWithStream(page, newPagePath, user, shouldUseV4Process = true) {
+  private async duplicateDescendantsWithStream(page, newPagePath, user, onlyDuplicateUserRelatedResources: boolean, shouldUseV4Process = true) {
     if (shouldUseV4Process) {
-      return this.duplicateDescendantsWithStreamV4(page, newPagePath, user);
+      return this.duplicateDescendantsWithStreamV4(page, newPagePath, user, onlyDuplicateUserRelatedResources);
     }
 
     const iterableFactory = new PageCursorsForDescendantsFactory(user, page, true);
@@ -1368,7 +1440,7 @@ class PageService implements IPageService {
         try {
           count += batch.length;
           nNonEmptyDuplicatedPages += batch.filter(page => !page.isEmpty).length;
-          await duplicateDescendants(batch, user, pathRegExp, newPagePathPrefix, shouldUseV4Process);
+          await duplicateDescendants(batch, user, pathRegExp, newPagePathPrefix, onlyDuplicateUserRelatedResources, shouldUseV4Process);
           logger.debug(`Adding pages progressing: (count=${count})`);
         }
         catch (err) {
@@ -1395,7 +1467,7 @@ class PageService implements IPageService {
     return nNonEmptyDuplicatedPages;
   }
 
-  private async duplicateDescendantsWithStreamV4(page, newPagePath, user) {
+  private async duplicateDescendantsWithStreamV4(page, newPagePath, user, onlyDuplicateUserRelatedResources: boolean) {
     const readStream = await this.generateReadStreamToOperateOnlyDescendants(page.path, user);
 
     const newPagePathPrefix = newPagePath;
@@ -1409,7 +1481,7 @@ class PageService implements IPageService {
       async write(batch, encoding, callback) {
         try {
           count += batch.length;
-          await duplicateDescendants(batch, user, pathRegExp, newPagePathPrefix);
+          await duplicateDescendants(batch, user, pathRegExp, newPagePathPrefix, onlyDuplicateUserRelatedResources);
           logger.debug(`Adding pages progressing: (count=${count})`);
         }
         catch (err) {
@@ -1464,10 +1536,10 @@ class PageService implements IPageService {
     }
 
     if (pagePathUtils.isUsersHomepage(page.path)) {
-      if (!this.crowi.pageService.canDeleteUserHomepageByConfig()) {
+      if (!this.canDeleteUserHomepageByConfig()) {
         throw new Error('User Homepage is not deletable.');
       }
-      if (!await this.crowi.pageService.isUsersHomepageOwnerAbsent(page.path)) {
+      if (!await this.isUsersHomepageOwnerAbsent(page.path)) {
         throw new Error('User Homepage is not deletable.');
       }
     }
@@ -2255,18 +2327,6 @@ class PageService implements IPageService {
     await PageOperation.findByIdAndDelete(pageOpId);
   }
 
-  /*
- * get all groups of Page that user is related to
- */
-  async getUserRelatedGrantedGroups(page: PageDocument, user): Promise<PopulatedGrantedGroup[]> {
-    const populatedPage = await page.populate<{grantedGroups: PopulatedGrantedGroup[] | null}>('grantedGroups.item');
-    const userRelatedGroupIds = [
-      ...(await UserGroupRelation.findAllGroupsForUser(user)).map(ugr => ugr._id.toString()),
-      ...(await ExternalUserGroupRelation.findAllGroupsForUser(user)).map(eugr => eugr._id.toString()),
-    ];
-    return populatedPage.grantedGroups?.filter(group => userRelatedGroupIds.includes(group.item._id.toString())) || [];
-  }
-
   private async revertDeletedPageV4(page, user, options = {}, isRecursively = false) {
     const Page = this.crowi.model('Page');
     const PageTagRelation = this.crowi.model('PageTagRelation');
@@ -2296,6 +2356,69 @@ class PageService implements IPageService {
     return updatedPage;
   }
 
+  private async applyScopesToDescendantsWithStream(parentPage, user, isV4 = false) {
+    const Page = this.crowi.model('Page');
+    const builder = new Page.PageQueryBuilder(Page.find());
+    builder.addConditionToListOnlyDescendants(parentPage.path);
+
+    if (isV4) {
+      builder.addConditionAsRootOrNotOnTree();
+    }
+    else {
+      builder.addConditionAsOnTree();
+    }
+
+    // add grant conditions
+    await Page.addConditionToFilteringByViewerToEdit(builder, user);
+
+    const grant = parentPage.grant;
+
+    const userRelatedGroups = await this.pageGrantService.getUserRelatedGroups(user);
+    const userRelatedParentGrantedGroups = this.pageGrantService.getUserRelatedGrantedGroupsSyncronously(
+      userRelatedGroups, parentPage,
+    );
+
+    const childPagesReadableStream = builder.query.cursor({ batchSize: BULK_REINDEX_SIZE });
+
+    const childPagesWritable = new Writable({
+      objectMode: true,
+      write: async(batch, encoding, callback) => {
+        await this.updateChildPagesGrant(batch, grant, user, userRelatedGroups, userRelatedParentGrantedGroups);
+        callback();
+      },
+    });
+
+    childPagesReadableStream
+      .pipe(createBatchStream(BULK_REINDEX_SIZE))
+      .pipe(childPagesWritable);
+    await streamToPromise(childPagesWritable);
+  }
+
+  async updateChildPagesGrant(
+      pages: PageDocument[], grant: PageGrant, user, userRelatedGroups: PopulatedGrantedGroup[], userRelatedParentGrantedGroups: IGrantedGroup[],
+  ): Promise<void> {
+    const Page = this.crowi.model('Page');
+    const operations: any = [];
+
+    pages.forEach((childPage) => {
+      let newChildGrantedGroups: IGrantedGroup[] = [];
+      if (grant === PageGrant.GRANT_USER_GROUP) {
+        newChildGrantedGroups = this.getNewGrantedGroupsSyncronously(userRelatedGroups, userRelatedParentGrantedGroups, childPage);
+      }
+      const canChangeGrant = this.pageGrantService
+        .validateGrantChangeSyncronously(userRelatedGroups, childPage.grantedGroups, PageGrant.GRANT_USER_GROUP, newChildGrantedGroups);
+      if (canChangeGrant) {
+        operations.push({
+          updateOne: {
+            filter: { _id: childPage._id },
+            update: { $set: { grant, grantedUsers: grant === PageGrant.GRANT_OWNER ? [user._id] : [], grantedGroups: newChildGrantedGroups } },
+          },
+        });
+      }
+    });
+    await Page.bulkWrite(operations);
+  }
+
   /**
    * Create revert stream
    */
@@ -2583,7 +2706,7 @@ class PageService implements IPageService {
     try {
       const shouldCheckDescendants = true;
 
-      isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, path, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
+      isGrantNormalized = await this.pageGrantService.isGrantNormalized(user, path, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
     }
     catch (err) {
       logger.error(`Failed to validate grant of page at "${path}"`, err);
@@ -2700,7 +2823,7 @@ class PageService implements IPageService {
       try {
         const shouldCheckDescendants = true;
 
-        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, path, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
+        isGrantNormalized = await this.pageGrantService.isGrantNormalized(user, path, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
       }
       catch (err) {
         logger.error(`Failed to validate grant of page at "${path}"`, err);
@@ -2746,7 +2869,7 @@ class PageService implements IPageService {
     let normalizablePages;
     let nonNormalizablePages;
     try {
-      [normalizablePages, nonNormalizablePages] = await this.crowi.pageGrantService.separateNormalizableAndNotNormalizablePages(user, pagesToNormalize);
+      [normalizablePages, nonNormalizablePages] = await this.pageGrantService.separateNormalizableAndNotNormalizablePages(user, pagesToNormalize);
     }
     catch (err) {
       socket.emit(SocketEventName.PageMigrationError);
@@ -3569,7 +3692,7 @@ class PageService implements IPageService {
   private async canProcessCreate(
       path: string,
       grantData: {
-        grant: number,
+        grant?: PageGrant,
         grantedUserIds?: ObjectIdLike[],
         grantUserGroupIds?: IGrantedGroup[],
       },
@@ -3606,7 +3729,7 @@ class PageService implements IPageService {
         const isEmptyPageAlreadyExist = await Page.count({ path, isEmpty: true }) > 0;
         const shouldCheckDescendants = isEmptyPageAlreadyExist && !options?.overwriteScopesOfDescendants;
 
-        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, path, grant, grantedUserIds, grantUserGroupIds, shouldCheckDescendants);
+        isGrantNormalized = await this.pageGrantService.isGrantNormalized(user, path, grant, grantedUserIds, grantUserGroupIds, shouldCheckDescendants);
       }
       catch (err) {
         logger.error(`Failed to validate grant of page at "${path}" of grant ${grant}:`, err);
@@ -3617,8 +3740,8 @@ class PageService implements IPageService {
       }
 
       if (options?.overwriteScopesOfDescendants) {
-        const updateGrantInfo = await this.crowi.pageGrantService.generateUpdateGrantInfoToOverwriteDescendants(user, grant, options.grantUserGroupIds);
-        const canOverwriteDescendants = await this.crowi.pageGrantService.canOverwriteDescendants(path, user, updateGrantInfo);
+        const updateGrantInfo = await this.pageGrantService.generateUpdateGrantInfoToOverwriteDescendants(user, grant, options.grantUserGroupIds);
+        const canOverwriteDescendants = await this.pageGrantService.canOverwriteDescendants(path, user, updateGrantInfo);
 
         if (!canOverwriteDescendants) {
           throw Error('Cannot overwrite scopes of descendants.');
@@ -3648,14 +3771,14 @@ class PageService implements IPageService {
     const {
       format = 'markdown', grantUserGroupIds,
     } = options;
-    const grant = isTopPage(path) ? Page.GRANT_PUBLIC : options.grant;
+    const grant = isTopPage(path) ? PageGrant.GRANT_PUBLIC : options.grant;
     const grantData = {
       grant,
-      grantedUserIds: grant === Page.GRANT_OWNER ? [user._id] : undefined,
+      grantedUserIds: grant === PageGrant.GRANT_OWNER ? [user._id] : undefined,
       grantUserGroupIds,
     };
 
-    const isGrantRestricted = grant === Page.GRANT_RESTRICTED;
+    const isGrantRestricted = grant === PageGrant.GRANT_RESTRICTED;
 
     // Validate
     const shouldValidateGrant = !isGrantRestricted;
@@ -3743,7 +3866,7 @@ class PageService implements IPageService {
 
     // update scopes for descendants
     if (options.overwriteScopesOfDescendants) {
-      await Page.applyScopesToDescendantsAsyncronously(page, user);
+      await this.applyScopesToDescendantsWithStream(page, user);
     }
 
     await PageOperation.findByIdAndDelete(pageOpId);
@@ -3795,7 +3918,7 @@ class PageService implements IPageService {
 
     // update scopes for descendants
     if (options.overwriteScopesOfDescendants) {
-      Page.applyScopesToDescendantsAsyncronously(savedPage, user, true);
+      this.applyScopesToDescendantsWithStream(savedPage, user, true);
     }
 
     return savedPage;
@@ -3804,7 +3927,7 @@ class PageService implements IPageService {
   private async canProcessForceCreateBySystem(
       path: string,
       grantData: {
-        grant: number,
+        grant: PageGrant,
         grantedUserIds?: ObjectIdLike[],
         grantUserGroupId?: ObjectIdLike,
       },
@@ -3903,12 +4026,12 @@ class PageService implements IPageService {
    * @param {UserDocument} user
    * @param options
    */
-  async updateGrant(page, user, grantData: {grant: PageGrant, grantedGroups: IGrantedGroup[]}): Promise<PageDocument> {
-    const { grant, grantedGroups } = grantData;
+  async updateGrant(page, user, grantData: {grant: PageGrant, userRelatedGrantedGroups: IGrantedGroup[]}): Promise<PageDocument> {
+    const { grant, userRelatedGrantedGroups } = grantData;
 
     const options: IOptionsForUpdate = {
       grant,
-      grantUserGroupIds: grantedGroups,
+      userRelatedGrantUserGroupIds: userRelatedGrantedGroups,
       isSyncRevisionToHackmd: false,
     };
 
@@ -3948,14 +4071,40 @@ class PageService implements IPageService {
 
     // 3. Update scopes for descendants
     if (options.overwriteScopesOfDescendants) {
-      await Page.applyScopesToDescendantsAsyncronously(currentPage, user);
+      await this.applyScopesToDescendantsWithStream(currentPage, user);
     }
 
     await PageOperation.findByIdAndDelete(pageOpId);
   }
 
+  /**
+   * Get the new GrantedGroups for the page going through an update operation.
+   * It will include the groups specified by the operator, and groups which the user does not belong to, but was related to the page before the update.
+   * @param userRelatedGrantedGroups The groups specified by the operator
+   * @param page The page going through an update operation
+   * @param user The operator
+   * @returns The new GrantedGroups array to be set to the page
+   */
+  async getNewGrantedGroups(userRelatedGrantedGroups: IGrantedGroup[], page: PageDocument, user): Promise<IGrantedGroup[]> {
+    const userRelatedGroups = await this.pageGrantService.getUserRelatedGroups(user);
+    return this.getNewGrantedGroupsSyncronously(userRelatedGroups, userRelatedGrantedGroups, page);
+  }
+
+  /**
+   * Use when you do not want to use getNewGrantedGroups with async/await (e.g inside loops that process a large amount of pages)
+   * Specification of userRelatedGroups is necessary to avoid the cost of fetching userRelatedGroups from DB every time.
+   */
+  getNewGrantedGroupsSyncronously(userRelatedGroups: PopulatedGrantedGroup[], userRelatedGrantedGroups: IGrantedGroup[], page: PageDocument): IGrantedGroup[] {
+    const previousGrantedGroups = page.grantedGroups;
+    const userRelatedPreviousGrantedGroups = this.pageGrantService.getUserRelatedGrantedGroupsSyncronously(
+      userRelatedGroups, page,
+    ).map(g => getIdForRef(g.item));
+    const userUnrelatedPreviousGrantedGroups = previousGrantedGroups.filter(g => !userRelatedPreviousGrantedGroups.includes(getIdForRef(g.item)));
+    return [...userUnrelatedPreviousGrantedGroups, ...userRelatedGrantedGroups];
+  }
+
   async updatePage(
-      pageData,
+      pageData: PageDocument,
       body: string | null,
       previousBody: string | null,
       user,
@@ -3977,21 +4126,22 @@ class PageService implements IPageService {
     const clonedPageData = Page.hydrate(pageData.toObject());
     const newPageData = pageData;
 
-    const grant = options.grant ?? clonedPageData.grant; // use the previous data if absence
-    const grantUserGroupIds = options.grantUserGroupIds ?? clonedPageData.grantedGroups;
+    // use the previous data if absent
+    const grant = options.grant ?? clonedPageData.grant;
+    const grantUserGroupIds = options.userRelatedGrantUserGroupIds != null
+      ? (await this.getNewGrantedGroups(options.userRelatedGrantUserGroupIds, clonedPageData, user))
+      : clonedPageData.grantedGroups;
 
     const grantedUserIds = clonedPageData.grantedUserIds || [user._id];
     const shouldBeOnTree = grant !== PageGrant.GRANT_RESTRICTED;
     const isChildrenExist = await Page.count({ path: new RegExp(`^${escapeStringRegexp(addTrailingSlash(clonedPageData.path))}`), parent: { $ne: null } });
 
-    const { pageService, pageGrantService } = this.crowi;
-
     if (shouldBeOnTree) {
       let isGrantNormalized = false;
       try {
         const shouldCheckDescendants = !options.overwriteScopesOfDescendants;
         // eslint-disable-next-line max-len
-        isGrantNormalized = await pageGrantService.isGrantNormalized(user, clonedPageData.path, grant, grantedUserIds, grantUserGroupIds, shouldCheckDescendants);
+        isGrantNormalized = await this.pageGrantService.isGrantNormalized(user, clonedPageData.path, grant, grantedUserIds, grantUserGroupIds, shouldCheckDescendants, false, pageData.grantedGroups);
       }
       catch (err) {
         logger.error(`Failed to validate grant of page at "${clonedPageData.path}" of grant ${grant}:`, err);
@@ -4002,8 +4152,8 @@ class PageService implements IPageService {
       }
 
       if (options.overwriteScopesOfDescendants) {
-        const updateGrantInfo = await pageGrantService.generateUpdateGrantInfoToOverwriteDescendants(user, grant, options.grantUserGroupIds);
-        const canOverwriteDescendants = await pageGrantService.canOverwriteDescendants(clonedPageData.path, user, updateGrantInfo);
+        const updateGrantInfo = await this.pageGrantService.generateUpdateGrantInfoToOverwriteDescendants(user, grant, options.userRelatedGrantUserGroupIds);
+        const canOverwriteDescendants = await this.pageGrantService.canOverwriteDescendants(clonedPageData.path, user, updateGrantInfo);
 
         if (!canOverwriteDescendants) {
           throw Error('Cannot overwrite scopes of descendants.');
@@ -4011,7 +4161,7 @@ class PageService implements IPageService {
       }
 
       if (!wasOnTree) {
-        const newParent = await pageService.getParentAndFillAncestorsByUser(user, newPageData.path);
+        const newParent = await this.getParentAndFillAncestorsByUser(user, newPageData.path);
         newPageData.parent = newParent._id;
       }
     }
@@ -4087,14 +4237,20 @@ class PageService implements IPageService {
   }
 
 
-  async updatePageV4(pageData, body, previousBody, user, options: IOptionsForUpdate = {}): Promise<PageDocument> {
+  async updatePageV4(pageData: PageDocument, 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 grantUserGroupIds = options.grantUserGroupIds || pageData.grantUserGroupIds; // use the previous data if absence
+    // use the previous data if absent
+    const grant = options.grant || pageData.grant;
+    const grantUserGroupIds = options.userRelatedGrantUserGroupIds != null
+      ? (await this.getNewGrantedGroups(options.userRelatedGrantUserGroupIds, pageData, user))
+      : pageData.grantedGroups;
     const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
 
+    // validate multiple group grant before save using pageData and options
+    await this.pageGrantService.validateGrantChange(user, pageData.grantedGroups, grant, grantUserGroupIds);
+
     await this.validateAppliedScope(user, grant, grantUserGroupIds);
     pageData.applyScope(user, grant, grantUserGroupIds);
 
@@ -4112,7 +4268,7 @@ class PageService implements IPageService {
 
     // update scopes for descendants
     if (options.overwriteScopesOfDescendants) {
-      Page.applyScopesToDescendantsAsyncronously(savedPage, user, true);
+      this.applyScopesToDescendantsWithStream(savedPage, user, true);
     }
 
 

+ 5 - 7
apps/app/src/server/service/search-delegator/elasticsearch.ts

@@ -368,13 +368,11 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       });
     }
 
-    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();
-      });
-    }
+    let grantedGroupIds = [];
+    grantedGroupIds = page.grantedGroups.map((group) => {
+      const groupId = (group.item._id == null) ? group.item : group.item._id;
+      return groupId.toString();
+    });
 
     return {
       grant: page.grant,

+ 124 - 29
apps/app/test/integration/models/v5.page.test.js

@@ -1,4 +1,4 @@
-import { PageGrant, GroupType } from '@growi/core';
+import { PageGrant, GroupType, getIdForRef } from '@growi/core';
 import mongoose from 'mongoose';
 
 import { ExternalGroupProviderType } from '../../../src/features/external-user-group/interfaces/external-user-group';
@@ -63,6 +63,8 @@ describe('Page', () => {
   const upodPageIdPublic5 = new mongoose.Types.ObjectId();
   const upodPageIdPublic6 = new mongoose.Types.ObjectId();
 
+  // Since updatePageSubOperation is asyncronously called from updatePageSubOperation,
+  // mock it inside updatePageSubOperation, and later call it independently to await for it's execution.
   const updatePage = async(page, newRevisionBody, oldRevisionBody, user, options = {}) => {
     const mockedUpdatePageSubOperation = jest.spyOn(pageService, 'updatePageSubOperation').mockReturnValue(null);
 
@@ -255,7 +257,22 @@ describe('Page', () => {
         creator: upodUserB,
         lastUpdateUser: upodUserB,
         grantedUsers: [upodUserB._id],
-        grantedGroups: null,
+        grantedGroups: [],
+        parent: upodPageIdgAB1,
+      },
+      // grant user A and B with independent groups
+      {
+        path: '/gAB_upod_1/gA_gB_upod_1',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroups: [
+          { item: upodUserGroupIdA, type: GroupType.userGroup },
+          { item: upodExternalUserGroupIdA, type: GroupType.externalUserGroup },
+          { item: upodUserGroupIdB, type: GroupType.userGroup },
+          { item: upodExternalUserGroupIdB, type: GroupType.externalUserGroup },
+        ],
         parent: upodPageIdgAB1,
       },
       // case 2
@@ -266,7 +283,7 @@ describe('Page', () => {
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: null,
-        grantedGroups: null,
+        grantedGroups: [],
         parent: rootPage._id,
       },
       {
@@ -299,7 +316,7 @@ describe('Page', () => {
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: [upodUserA._id],
-        grantedGroups: null,
+        grantedGroups: [],
         parent: upodPageIdPublic2,
       },
       // case 3
@@ -310,9 +327,11 @@ describe('Page', () => {
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: null,
-        grantedGroups: null,
+        grantedGroups: [],
         parent: rootPage._id,
       },
+      // grant user A and B with a single group
+      // (external group is extra for testing external groups)
       {
         path: '/public_upod_3/gAB_upod_3',
         grant: PageGrant.GRANT_USER_GROUP,
@@ -325,6 +344,21 @@ describe('Page', () => {
         ],
         parent: upodPageIdPublic3,
       },
+      // grant user A and B with independent groups
+      {
+        path: '/public_upod_3/gA_gB_upod_3',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroups: [
+          { item: upodUserGroupIdA, type: GroupType.userGroup },
+          { item: upodExternalUserGroupIdA, type: GroupType.externalUserGroup },
+          { item: upodUserGroupIdB, type: GroupType.userGroup },
+          { item: upodExternalUserGroupIdB, type: GroupType.externalUserGroup },
+        ],
+        parent: upodPageIdPublic3,
+      },
       {
         path: '/public_upod_3/gB_upod_3',
         grant: PageGrant.GRANT_USER_GROUP,
@@ -343,7 +377,7 @@ describe('Page', () => {
         creator: upodUserB,
         lastUpdateUser: upodUserB,
         grantedUsers: [upodUserB._id],
-        grantedGroups: null,
+        grantedGroups: [],
         parent: upodPageIdPublic3,
       },
       // case 4
@@ -354,7 +388,7 @@ describe('Page', () => {
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: null,
-        grantedGroups: null,
+        grantedGroups: [],
         parent: rootPage._id,
       },
       {
@@ -389,7 +423,7 @@ describe('Page', () => {
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: null,
-        grantedGroups: null,
+        grantedGroups: [],
         parent: rootPage._id,
       },
       {
@@ -410,7 +444,7 @@ describe('Page', () => {
         creator: upodUserC,
         lastUpdateUser: upodUserC,
         grantedUsers: [upodUserC._id],
-        grantedGroups: null,
+        grantedGroups: [],
         parent: upodPageIdPublic5,
       },
       // case 6
@@ -421,7 +455,7 @@ describe('Page', () => {
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: null,
-        grantedGroups: null,
+        grantedGroups: [],
         parent: rootPage._id,
       },
       {
@@ -430,7 +464,7 @@ describe('Page', () => {
         creator: upodUserC,
         lastUpdateUser: upodUserC,
         grantedUsers: [upodUserC._id],
-        grantedGroups: null,
+        grantedGroups: [],
         parent: upodPageIdPublic6,
       },
     ]);
@@ -439,8 +473,7 @@ describe('Page', () => {
   // 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 };
+      return { item: getIdForRef(group.item), type: group.type };
     });
   };
 
@@ -996,12 +1029,24 @@ describe('Page', () => {
         parent: rootPage._id,
         descendantCount: 0,
       },
+      {
+        path: '/with_multiple_individual_granted_groups',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroups: [
+          { item: userGroupIdPModelA, type: GroupType.userGroup },
+          { item: userGroupIdPModelB, type: GroupType.userGroup },
+        ],
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: rootPage,
+      },
     ]);
 
     await createDocumentsToTestUpdatePageOverwritingDescendants();
   });
 
-  describe('update', () => {
+  describe('updatePage with overwriteScopesOfDescendants false', () => {
     describe('Changing grant from PUBLIC to RESTRICTED of', () => {
       test('an only-child page will delete its empty parent page', async() => {
         const pathT = '/mup13_top';
@@ -1014,7 +1059,7 @@ describe('Page', () => {
         expect(page1).toBeTruthy();
         expect(page2).toBeTruthy();
 
-        const options = { grant: Page.GRANT_RESTRICTED, grantUserGroupIds: null };
+        const options = { grant: Page.GRANT_RESTRICTED, userRelatedGrantUserGroupIds: null };
         await updatePage(page2, 'newRevisionBody', 'oldRevisionBody', dummyUser1, options);
 
         const _pageT = await Page.findOne({ path: pathT });
@@ -1221,7 +1266,7 @@ describe('Page', () => {
 
           const options = {
             grant: Page.GRANT_USER_GROUP,
-            grantUserGroupIds: newGrantedGroups,
+            userRelatedGrantUserGroupIds: newGrantedGroups,
           };
           const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser1, options); // from GRANT_PUBLIC to GRANT_USER_GROUP(userGroupIdPModelA)
 
@@ -1251,7 +1296,7 @@ describe('Page', () => {
 
           const options = {
             grant: Page.GRANT_USER_GROUP,
-            grantUserGroupIds: newGrantedGroups,
+            userRelatedGrantUserGroupIds: newGrantedGroups,
           };
           const updatedPage = await updatePage(_page1, 'new', 'old', pModelUser1, options); // from GRANT_RESTRICTED to GRANT_USER_GROUP(userGroupIdPModelA)
 
@@ -1289,7 +1334,7 @@ describe('Page', () => {
 
           const options = {
             grant: Page.GRANT_USER_GROUP,
-            grantUserGroupIds: newGrantedGroups,
+            userRelatedGrantUserGroupIds: newGrantedGroups,
           };
           const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser1, options); // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelA)
 
@@ -1327,7 +1372,7 @@ describe('Page', () => {
           ];
           const options = {
             grant: Page.GRANT_USER_GROUP,
-            grantUserGroupIds: newGrantedGroups,
+            userRelatedGrantUserGroupIds: newGrantedGroups,
           };
           const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser3, options); // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelB)
 
@@ -1348,7 +1393,7 @@ describe('Page', () => {
             { 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)
+          const secondRoundOptions = { grant: Page.GRANT_USER_GROUP, userRelatedGrantUserGroupIds: 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;
@@ -1379,7 +1424,7 @@ describe('Page', () => {
 
           const options = {
             grant: Page.GRANT_USER_GROUP,
-            grantUserGroupIds: [
+            userRelatedGrantUserGroupIds: [
               { item: userGroupIdPModelIsolate, type: GroupType.userGroup },
               { item: externalUserGroupIdPModelIsolate, type: GroupType.externalUserGroup },
             ],
@@ -1410,7 +1455,7 @@ describe('Page', () => {
 
           const options = {
             grant: Page.GRANT_USER_GROUP,
-            grantUserGroupIds: [
+            userRelatedGrantUserGroupIds: [
               { item: userGroupIdPModelA, type: GroupType.userGroup },
               { item: externalUserGroupIdPModelA, type: GroupType.externalUserGroup },
             ],
@@ -1444,7 +1489,7 @@ describe('Page', () => {
           expect(_page1).toBeTruthy();
           expect(_page2).toBeTruthy();
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupIds: [{ item: userGroupIdPModelA, type: GroupType.userGroup }] };
+          const options = { grant: Page.GRANT_USER_GROUP, userRelatedGrantUserGroupIds: [{ 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.'));
 
@@ -1457,9 +1502,40 @@ describe('Page', () => {
           expect(page2.grantedGroups.length).toBe(0); // no group should be set
         });
       });
+      describe('update grant of a page from GRANT_USER_GROUP to GRANT_USER_GROUP', () => {
+        test('successfully change the granted groups, with the previous groups wich user is not related to remaining', async() => {
+          // path
+          const path = '/with_multiple_individual_granted_groups';
+          // page
+          const _page = await Page.findOne({ path, grant: Page.GRANT_USER_GROUP });
+          expect(_page).toBeTruthy();
 
-    });
+          const newUserRelatedGrantedGroups = [
+            { item: userGroupIdPModelA, type: GroupType.userGroup },
+            { item: externalUserGroupIdPModelA, type: GroupType.externalUserGroup },
+          ];
 
+          const options = {
+            grant: Page.GRANT_USER_GROUP,
+            userRelatedGrantUserGroupIds: newUserRelatedGrantedGroups,
+          };
+          const updatedPage = await updatePage(_page, 'new', 'old', pModelUser1, options); // from GRANT_PUBLIC to GRANT_USER_GROUP(userGroupIdPModelA)
+
+          const page = await Page.findById(_page._id);
+          expect(page).toBeTruthy();
+          expect(updatedPage).toBeTruthy();
+          expect(updatedPage._id).toStrictEqual(page._id);
+
+          // check page grant and group
+          expect(page.grant).toBe(Page.GRANT_USER_GROUP);
+          expect(normalizeGrantedGroups(page.grantedGroups)).toEqual(expect.arrayContaining([
+            ...newUserRelatedGrantedGroups,
+            // userB group remains, although options does not include it
+            { item: userGroupIdPModelB, type: GroupType.userGroup },
+          ]));
+        });
+      });
+    });
   });
 
 
@@ -1469,14 +1545,17 @@ describe('Page', () => {
       const upodPagegAB = await Page.findOne({ path: '/gAB_upod_1' });
       const upodPagegB = await Page.findOne({ path: '/gAB_upod_1/gB_upod_1' });
       const upodPageonlyB = await Page.findOne({ path: '/gAB_upod_1/onlyB_upod_1' });
+      const upodPagegAgB = await Page.findOne({ path: '/gAB_upod_1/gA_gB_upod_1' });
 
       expect(upodPagegAB).not.toBeNull();
       expect(upodPagegB).not.toBeNull();
       expect(upodPageonlyB).not.toBeNull();
+      expect(upodPagegAgB).not.toBeNull();
 
       expect(upodPagegAB.grant).toBe(PageGrant.GRANT_USER_GROUP);
       expect(upodPagegB.grant).toBe(PageGrant.GRANT_USER_GROUP);
       expect(upodPageonlyB.grant).toBe(PageGrant.GRANT_OWNER);
+      expect(upodPagegAgB.grant).toBe(PageGrant.GRANT_USER_GROUP);
 
       // Update
       const options = {
@@ -1487,6 +1566,7 @@ describe('Page', () => {
 
       const upodPagegBUpdated = await Page.findOne({ path: '/gAB_upod_1/gB_upod_1' });
       const upodPageonlyBUpdated = await Page.findOne({ path: '/gAB_upod_1/onlyB_upod_1' });
+      const upodPagegAgBUpdated = await Page.findOne({ path: '/gAB_upod_1/gA_gB_upod_1' });
 
       // Changed
       const newGrant = PageGrant.GRANT_PUBLIC;
@@ -1496,8 +1576,10 @@ describe('Page', () => {
       expect(upodPagegBUpdated.grantedGroups).toStrictEqual(upodPagegB.grantedGroups);
       expect(upodPageonlyBUpdated.grant).toBe(PageGrant.GRANT_OWNER);
       expect(upodPageonlyBUpdated.grantedUsers).toStrictEqual(upodPageonlyB.grantedUsers);
+      expect(upodPagegAgBUpdated.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPagegAgBUpdated.grantedGroups).toStrictEqual(upodPagegAgB.grantedGroups);
     });
-    test('(case 2) it should update all granted descendant pages when all descendant pages are granted by the operator', async() => {
+    test('(case 2) it should update all granted descendant pages when all descendant pages are granted to the operator', async() => {
       const upodPagePublic = await Page.findOne({ path: '/public_upod_2' });
       const upodPagegA = await Page.findOne({ path: '/public_upod_2/gA_upod_2' });
       const upodPagegAIsolated = await Page.findOne({ path: '/public_upod_2/gAIsolated_upod_2' });
@@ -1541,23 +1623,26 @@ describe('Page', () => {
     , and all users of descendants belong to the update user group`, async() => {
       const upodPagePublic = await Page.findOne({ path: '/public_upod_3' });
       const upodPagegAB = await Page.findOne({ path: '/public_upod_3/gAB_upod_3' });
+      const upodPagegAgB = await Page.findOne({ path: '/public_upod_3/gA_gB_upod_3' });
       const upodPagegB = await Page.findOne({ path: '/public_upod_3/gB_upod_3' });
       const upodPageonlyB = await Page.findOne({ path: '/public_upod_3/onlyB_upod_3' });
 
       expect(upodPagePublic).not.toBeNull();
       expect(upodPagegAB).not.toBeNull();
+      expect(upodPagegAgB).not.toBeNull();
       expect(upodPagegB).not.toBeNull();
       expect(upodPageonlyB).not.toBeNull();
 
       expect(upodPagePublic.grant).toBe(PageGrant.GRANT_PUBLIC);
       expect(upodPagegAB.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPagegAgB.grant).toBe(PageGrant.GRANT_USER_GROUP);
       expect(upodPagegB.grant).toBe(PageGrant.GRANT_USER_GROUP);
       expect(upodPageonlyB.grant).toBe(PageGrant.GRANT_OWNER);
 
       // Update
       const options = {
         grant: PageGrant.GRANT_USER_GROUP,
-        grantUserGroupIds: [
+        userRelatedGrantUserGroupIds: [
           { item: upodUserGroupIdAB, type: GroupType.userGroup },
           { item: upodExternalUserGroupIdAB, type: GroupType.externalUserGroup },
         ],
@@ -1566,6 +1651,7 @@ describe('Page', () => {
       const updatedPage = await updatePage(upodPagePublic, 'newRevisionBody', 'oldRevisionBody', upodUserA, options);
 
       const upodPagegABUpdated = await Page.findOne({ path: '/public_upod_3/gAB_upod_3' });
+      const upodPagegAgBUpdated = await Page.findOne({ path: '/public_upod_3/gA_gB_upod_3' });
       const upodPagegBUpdated = await Page.findOne({ path: '/public_upod_3/gB_upod_3' });
       const upodPageonlyBUpdated = await Page.findOne({ path: '/public_upod_3/onlyB_upod_3' });
 
@@ -1579,6 +1665,15 @@ describe('Page', () => {
       expect(normalizeGrantedGroups(updatedPage.grantedGroups)).toStrictEqual(newGrantedGroups);
       expect(upodPagegABUpdated.grant).toBe(newGrant);
       expect(normalizeGrantedGroups(upodPagegABUpdated.grantedGroups)).toStrictEqual(newGrantedGroups);
+      expect(upodPagegAgBUpdated.grant).toBe(newGrant);
+      // For multi group granted pages, the grant update will only add/remove groups that the user belongs to,
+      // and groups that the user doesn't belong to will stay as it was before the update.
+      expect(normalizeGrantedGroups(upodPagegAgBUpdated.grantedGroups)).toEqual(expect.arrayContaining([
+        ...newGrantedGroups,
+        { item: upodUserGroupIdB, type: GroupType.userGroup },
+        { item: upodExternalUserGroupIdB, type: GroupType.externalUserGroup },
+      ]));
+
       // Not changed
       expect(upodPagegBUpdated.grant).toBe(PageGrant.GRANT_USER_GROUP);
       expect(upodPagegBUpdated.grantedGroups).toStrictEqual(upodPagegB.grantedGroups);
@@ -1603,7 +1698,7 @@ describe('Page', () => {
       // Update
       const options = {
         grant: PageGrant.GRANT_USER_GROUP,
-        grantUserGroupIds: [
+        userRelatedGrantUserGroupIds: [
           { item: upodUserGroupIdAB, type: GroupType.userGroup },
           { item: upodExternalUserGroupIdAB, type: GroupType.externalUserGroup },
         ],
@@ -1631,7 +1726,7 @@ describe('Page', () => {
       // Update
       const options = {
         grant: PageGrant.GRANT_USER_GROUP,
-        grantUserGroupIds: [
+        userRelatedGrantUserGroupIds: [
           { item: upodUserGroupIdAB, type: GroupType.userGroup },
           { item: upodExternalUserGroupIdAB, type: GroupType.externalUserGroup },
         ],
@@ -1654,7 +1749,7 @@ describe('Page', () => {
       // Update
       const options = {
         grant: PageGrant.GRANT_USER_GROUP,
-        grantUserGroupIds: [
+        userRelatedGrantUserGroupIds: [
           { item: upodUserGroupIdAB, type: GroupType.userGroup },
           { item: upodExternalUserGroupIdAB, type: GroupType.externalUserGroup },
         ],

+ 108 - 13
apps/app/test/integration/service/page-grant.test.js

@@ -33,6 +33,7 @@ describe('PageGrantService', () => {
 
   let groupParent;
   let groupChild;
+  let differentTreeGroup;
 
   let externalGroupParent;
   let externalGroupChild;
@@ -51,10 +52,13 @@ describe('PageGrantService', () => {
   const emptyPagePath2 = '/E2';
   const emptyPagePath3 = '/E3';
 
+  let multipleGroupTreesAndUsersPage;
+
   let pageRootPublic;
   let pageRootGroupParent;
   const pageRootPublicPath = '/Public';
   const pageRootGroupParentPath = '/GroupParent';
+  const pageMultipleGroupTreesAndUsersPath = '/MultipleGroupTreesAndUsers';
 
   const v4PageRootOnlyMePagePath = '/v4OnlyMe';
   const v4PageRootAnyoneWithTheLinkPagePath = '/v4AnyoneWithTheLink';
@@ -102,10 +106,15 @@ describe('PageGrantService', () => {
         name: 'GroupChild',
         parent: userGroupIdParent,
       },
+      {
+        name: 'DifferentTreeGroup',
+        parent: null,
+      },
     ]);
 
     groupParent = await UserGroup.findOne({ name: 'GroupParent' });
     groupChild = await UserGroup.findOne({ name: 'GroupChild' });
+    differentTreeGroup = await UserGroup.findOne({ name: 'DifferentTreeGroup' });
 
     // UserGroupRelations
     await UserGroupRelation.insertMany([
@@ -121,6 +130,10 @@ describe('PageGrantService', () => {
         relatedGroup: groupChild._id,
         relatedUser: user1._id,
       },
+      {
+        relatedGroup: differentTreeGroup._id,
+        relatedUser: user1._id,
+      },
     ]);
 
     await ExternalUserGroup.insertMany([
@@ -186,7 +199,7 @@ describe('PageGrantService', () => {
         creator: user1,
         lastUpdateUser: user1,
         grantedUsers: null,
-        grantedGroups: null,
+        grantedGroups: [],
         parent: rootPage._id,
       },
       {
@@ -198,8 +211,19 @@ describe('PageGrantService', () => {
         grantedGroups: [{ item: groupParent._id, type: GroupType.userGroup }, { item: externalGroupParent._id, type: GroupType.externalUserGroup }],
         parent: rootPage._id,
       },
+      {
+        path: pageMultipleGroupTreesAndUsersPath,
+        grant: Page.GRANT_USER_GROUP,
+        creator: user1,
+        lastUpdateUser: user1,
+        grantedUsers: null,
+        grantedGroups: [{ item: groupParent._id, type: GroupType.userGroup }, { item: differentTreeGroup._id, type: GroupType.userGroup }],
+        parent: null,
+      },
     ]);
 
+    multipleGroupTreesAndUsersPage = await Page.findOne({ path: pageMultipleGroupTreesAndUsersPath });
+
     await Page.insertMany([
       // Root Page
       {
@@ -304,7 +328,7 @@ describe('PageGrantService', () => {
         creator: user1,
         lastUpdateUser: user1,
         grantedUsers: null,
-        grantedGroups: null,
+        grantedGroups: [],
         parent: emptyPage1._id,
       },
       {
@@ -313,7 +337,7 @@ describe('PageGrantService', () => {
         creator: user1,
         lastUpdateUser: user1,
         grantedUsers: [user1._id],
-        grantedGroups: null,
+        grantedGroups: [],
         parent: emptyPage2._id,
       },
       {
@@ -340,7 +364,7 @@ describe('PageGrantService', () => {
         creator: user1,
         lastUpdateUser: user1,
         grantedUsers: [user1._id],
-        grantedGroups: null,
+        grantedGroups: [],
         parent: emptyPage3._id,
       },
     ]);
@@ -375,7 +399,7 @@ describe('PageGrantService', () => {
       const targetPath = '/NEW';
       const grant = Page.GRANT_PUBLIC;
       const grantedUserIds = null;
-      const grantedGroupIds = null;
+      const grantedGroupIds = [];
       const shouldCheckDescendants = false;
 
       const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
@@ -399,7 +423,7 @@ describe('PageGrantService', () => {
       const targetPath = `${pageRootPublicPath}/NEW`;
       const grant = Page.GRANT_PUBLIC;
       const grantedUserIds = null;
-      const grantedGroupIds = null;
+      const grantedGroupIds = [];
       const shouldCheckDescendants = false;
 
       const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
@@ -423,7 +447,7 @@ describe('PageGrantService', () => {
       const targetPath = `${pageE1PublicPath}/NEW`;
       const grant = Page.GRANT_PUBLIC;
       const grantedUserIds = null;
-      const grantedGroupIds = null;
+      const grantedGroupIds = [];
       const shouldCheckDescendants = false;
 
       const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
@@ -435,7 +459,7 @@ describe('PageGrantService', () => {
       const targetPath = `${pageE2User1Path}/NEW`;
       const grant = Page.GRANT_OWNER;
       const grantedUserIds = [user1._id];
-      const grantedGroupIds = null;
+      const grantedGroupIds = [];
       const shouldCheckDescendants = false;
 
       const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
@@ -447,7 +471,7 @@ describe('PageGrantService', () => {
       const targetPath = `${pageE3GroupParentPath}/NEW`;
       const grant = Page.GRANT_PUBLIC;
       const grantedUserIds = null;
-      const grantedGroupIds = null;
+      const grantedGroupIds = [];
       const shouldCheckDescendants = false;
 
       const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
@@ -473,7 +497,7 @@ describe('PageGrantService', () => {
       const targetPath = emptyPagePath1;
       const grant = Page.GRANT_PUBLIC;
       const grantedUserIds = null;
-      const grantedGroupIds = null;
+      const grantedGroupIds = [];
       const shouldCheckDescendants = true;
 
       const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
@@ -485,7 +509,7 @@ describe('PageGrantService', () => {
       const targetPath = emptyPagePath2;
       const grant = Page.GRANT_OWNER;
       const grantedUserIds = [user1._id];
-      const grantedGroupIds = null;
+      const grantedGroupIds = [];
       const shouldCheckDescendants = true;
 
       const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
@@ -505,11 +529,11 @@ describe('PageGrantService', () => {
       expect(result).toBe(true);
     });
 
-    test('Should return false when Target: owned by UserA, Descendant: public', async() => {
+    test('Should return false when Target: owned by User1, Descendant: public', async() => {
       const targetPath = emptyPagePath1;
       const grant = Page.GRANT_OWNER;
       const grantedUserIds = [user1._id];
-      const grantedGroupIds = null;
+      const grantedGroupIds = [];
       const shouldCheckDescendants = true;
 
       const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
@@ -518,6 +542,77 @@ describe('PageGrantService', () => {
     });
   });
 
+  describe('Test isGrantNormalized method with previousGrantedGroupIds given', () => {
+    test('Should return true when Target: completely owned by User1 (belongs to all groups)', async() => {
+      const targetPath = pageMultipleGroupTreesAndUsersPath;
+      const grant = Page.GRANT_PUBLIC;
+      const grantedUserIds = null;
+      const grantedGroupIds = [];
+      const shouldCheckDescendants = false;
+
+      const result = await pageGrantService.isGrantNormalized(
+        user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants, false, multipleGroupTreesAndUsersPage.grantedGroups,
+      );
+
+      expect(result).toBe(true);
+    });
+
+    test('Should return false when Target: partially owned by User2 (belongs to one of the groups), and change to public grant', async() => {
+      const targetPath = pageMultipleGroupTreesAndUsersPath;
+      const grant = Page.GRANT_PUBLIC;
+      const grantedUserIds = null;
+      const grantedGroupIds = [];
+      const shouldCheckDescendants = false;
+
+      const result = await pageGrantService.isGrantNormalized(
+        user2, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants, false, multipleGroupTreesAndUsersPage.grantedGroups,
+      );
+
+      expect(result).toBe(false);
+    });
+
+    test('Should return false when Target: partially owned by User2 (belongs to one of the groups), and change to owner grant', async() => {
+      const targetPath = pageMultipleGroupTreesAndUsersPath;
+      const grant = Page.GRANT_OWNER;
+      const grantedUserIds = [user2._id];
+      const grantedGroupIds = [];
+      const shouldCheckDescendants = false;
+
+      const result = await pageGrantService.isGrantNormalized(
+        user2, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants, false, multipleGroupTreesAndUsersPage.grantedGroups,
+      );
+
+      expect(result).toBe(false);
+    });
+
+    test('Should return false when Target: partially owned by User2 (belongs to one of the groups), and change to restricted grant', async() => {
+      const targetPath = pageMultipleGroupTreesAndUsersPath;
+      const grant = Page.GRANT_RESTRICTED;
+      const grantedUserIds = null;
+      const grantedGroupIds = [];
+      const shouldCheckDescendants = false;
+
+      const result = await pageGrantService.isGrantNormalized(
+        user2, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants, false, multipleGroupTreesAndUsersPage.grantedGroups,
+      );
+
+      expect(result).toBe(false);
+    });
+
+    test('Should return false when Target: partially owned by User2, and change to group grant without any groups of user2', async() => {
+      const targetPath = pageMultipleGroupTreesAndUsersPath;
+      const grant = Page.GRANT_USER_GROUP;
+      const grantedUserIds = null;
+      const grantedGroupIds = [{ item: differentTreeGroup._id, type: GroupType.userGroup }];
+      const shouldCheckDescendants = false;
+
+      const result = await pageGrantService.isGrantNormalized(
+        user2, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants, false, multipleGroupTreesAndUsersPage.grantedGroups,
+      );
+
+      expect(result).toBe(false);
+    });
+  });
 
   describe('Test for calcApplicableGrantData', () => {
     test('Only Public is Applicable in case of top page', async() => {

+ 184 - 48
apps/app/test/integration/service/page.test.js

@@ -1,7 +1,12 @@
 /* eslint-disable no-unused-vars */
+import { GroupType } from '@growi/core';
 import { advanceTo } from 'jest-date-mock';
 
+import { PageSingleDeleteCompConfigValue, PageRecursiveDeleteCompConfigValue } from '~/interfaces/page-delete-config';
 import Tag from '~/server/models/tag';
+import UserGroup from '~/server/models/user-group';
+import UserGroupRelation from '~/server/models/user-group-relation';
+
 
 const mongoose = require('mongoose');
 
@@ -12,6 +17,7 @@ let rootPage;
 let dummyUser1;
 let testUser1;
 let testUser2;
+let testUser3;
 let parentTag;
 let childTag;
 
@@ -39,6 +45,7 @@ let parentForDelete2;
 
 let childForDelete;
 
+let canDeleteCompletelyTestPage;
 let parentForDeleteCompletely;
 
 let parentForRevert1;
@@ -76,13 +83,47 @@ describe('PageService', () => {
     await User.insertMany([
       { name: 'someone1', username: 'someone1', email: 'someone1@example.com' },
       { name: 'someone2', username: 'someone2', email: 'someone2@example.com' },
+      { name: 'someone3', username: 'someone3', email: 'someone3@example.com' },
     ]);
 
     testUser1 = await User.findOne({ username: 'someone1' });
     testUser2 = await User.findOne({ username: 'someone2' });
+    testUser3 = await User.findOne({ username: 'someone3' });
 
     dummyUser1 = await User.findOne({ username: 'v5DummyUser1' });
 
+    await UserGroup.insertMany([
+      {
+        name: 'userGroupForCanDeleteCompletelyTest1',
+        parent: null,
+      },
+      {
+        name: 'userGroupForCanDeleteCompletelyTest2',
+        parent: null,
+      },
+    ]);
+    const userGroupForCanDeleteCompletelyTest1 = await UserGroup.findOne({ name: 'userGroupForCanDeleteCompletelyTest1' });
+    const userGroupForCanDeleteCompletelyTest2 = await UserGroup.findOne({ name: 'userGroupForCanDeleteCompletelyTest2' });
+
+    await UserGroupRelation.insertMany([
+      {
+        relatedGroup: userGroupForCanDeleteCompletelyTest1._id,
+        relatedUser: testUser1._id,
+      },
+      {
+        relatedGroup: userGroupForCanDeleteCompletelyTest2._id,
+        relatedUser: testUser2._id,
+      },
+      {
+        relatedGroup: userGroupForCanDeleteCompletelyTest1._id,
+        relatedUser: testUser3._id,
+      },
+      {
+        relatedGroup: userGroupForCanDeleteCompletelyTest2._id,
+        relatedUser: testUser3._id,
+      },
+    ]);
+
     rootPage = await Page.findOne({ path: '/' });
 
     await Page.insertMany([
@@ -170,6 +211,16 @@ describe('PageService', () => {
         creator: testUser1,
         lastUpdateUser: testUser1,
       },
+      {
+        path: '/canDeleteCompletelyTestPage',
+        grant: Page.GRANT_USER_GROUP,
+        creator: testUser2,
+        grantedGroups: [
+          { item: userGroupForCanDeleteCompletelyTest1._id, type: GroupType.userGroup },
+          { item: userGroupForCanDeleteCompletelyTest2._id, type: GroupType.userGroup },
+        ],
+        lastUpdateUser: testUser1,
+      },
       {
         path: '/parentForDuplicate',
         grant: Page.GRANT_PUBLIC,
@@ -255,6 +306,7 @@ describe('PageService', () => {
     parentForDelete1 = await Page.findOne({ path: '/parentForDelete1' });
     parentForDelete2 = await Page.findOne({ path: '/parentForDelete2' });
 
+    canDeleteCompletelyTestPage = await Page.findOne({ path: '/canDeleteCompletelyTestPage' });
     parentForDeleteCompletely = await Page.findOne({ path: '/parentForDeleteCompletely' });
     parentForRevert1 = await Page.findOne({ path: '/trash/parentForRevert1' });
     parentForRevert2 = await Page.findOne({ path: '/trash/parentForRevert2' });
@@ -703,67 +755,151 @@ describe('PageService', () => {
   });
 
   describe('delete page completely', () => {
-    let pageEventSpy;
-    let deleteCompletelyOperationSpy;
-    let deleteCompletelyDescendantsWithStreamSpy;
-
-    let deleteManyBookmarkSpy;
-    let deleteManyCommentSpy;
-    let deleteManyPageTagRelationSpy;
-    let deleteManyShareLinkSpy;
-    let deleteManyRevisionSpy;
-    let deleteManyPageSpy;
-    let removeAllAttachmentsSpy;
+    describe('canDeleteCompletely', () => {
+      describe(`when user is not admin or author,
+        pageCompleteDeletionAuthority is 'anyone',
+        user is not related to all granted groups,
+        and isAllGroupMembershipRequiredForPageCompleteDeletion is true`, () => {
+        beforeEach(async() => {
+          const config = {
+            'security:isAllGroupMembershipRequiredForPageCompleteDeletion': true,
+            'security:pageCompleteDeletionAuthority': PageSingleDeleteCompConfigValue.Anyone,
+            'security:pageRecursiveCompleteDeletionAuthority': PageRecursiveDeleteCompConfigValue.Anyone,
+          };
+          await crowi.configManager.updateConfigsInTheSameNamespace('crowi', config);
+        });
+
+        test('is not deletable', async() => {
+          const userRelatedGroups = await crowi.pageGrantService.getUserRelatedGroups(testUser1);
+          const isDeleteable = crowi.pageService.canDeleteCompletely(canDeleteCompletelyTestPage, testUser1, false, userRelatedGroups);
+          expect(isDeleteable).toBe(false);
+        });
+      });
 
-    beforeEach(async() => {
-      pageEventSpy = jest.spyOn(crowi.pageService.pageEvent, 'emit');
-      deleteCompletelyOperationSpy = jest.spyOn(crowi.pageService, 'deleteCompletelyOperation');
-      deleteCompletelyDescendantsWithStreamSpy = jest.spyOn(crowi.pageService, 'deleteCompletelyDescendantsWithStream').mockImplementation();
-
-      deleteManyBookmarkSpy = jest.spyOn(Bookmark, 'deleteMany').mockImplementation();
-      deleteManyCommentSpy = jest.spyOn(Comment, 'deleteMany').mockImplementation();
-      deleteManyPageTagRelationSpy = jest.spyOn(PageTagRelation, 'deleteMany').mockImplementation();
-      deleteManyShareLinkSpy = jest.spyOn(ShareLink, 'deleteMany').mockImplementation();
-      deleteManyRevisionSpy = jest.spyOn(Revision, 'deleteMany').mockImplementation();
-      deleteManyPageSpy = jest.spyOn(Page, 'deleteMany').mockImplementation();
-      removeAllAttachmentsSpy = jest.spyOn(crowi.attachmentService, 'removeAllAttachments').mockImplementation();
-    });
+      describe(`when user is not admin or author,
+        pageCompleteDeletionAuthority is 'anyone',
+        user is related to all granted groups,
+        and isAllGroupMembershipRequiredForPageCompleteDeletion is true`, () => {
+        beforeEach(async() => {
+          const config = {
+            'security:isAllGroupMembershipRequiredForPageCompleteDeletion': true,
+            'security:pageCompleteDeletionAuthority': PageSingleDeleteCompConfigValue.Anyone,
+            'security:pageRecursiveCompleteDeletionAuthority': PageRecursiveDeleteCompConfigValue.Anyone,
+          };
+          await crowi.configManager.updateConfigsInTheSameNamespace('crowi', config);
+        });
+
+        test('is not deletable', async() => {
+          const userRelatedGroups = await crowi.pageGrantService.getUserRelatedGroups(testUser3);
+          const isDeleteable = crowi.pageService.canDeleteCompletely(canDeleteCompletelyTestPage, testUser3, false, userRelatedGroups);
+          expect(isDeleteable).toBe(true);
+        });
+      });
 
-    test('deleteCompletelyOperation', async() => {
-      await crowi.pageService.deleteCompletelyOperation([parentForDeleteCompletely._id], [parentForDeleteCompletely.path], { });
+      describe(`when user is not admin or author,
+        pageCompleteDeletionAuthority is 'anyone',
+        user is not related to all granted groups,
+        and isAllGroupMembershipRequiredForPageCompleteDeletion is false`, () => {
+        beforeEach(async() => {
+          const config = {
+            'security:isAllGroupMembershipRequiredForPageCompleteDeletion': false,
+            'security:pageCompleteDeletionAuthority': PageSingleDeleteCompConfigValue.Anyone,
+            'security:pageRecursiveCompleteDeletionAuthority': PageRecursiveDeleteCompConfigValue.Anyone,
+          };
+          await crowi.configManager.updateConfigsInTheSameNamespace('crowi', config);
+        });
+
+        test('is deletable', async() => {
+          const userRelatedGroups = await crowi.pageGrantService.getUserRelatedGroups(testUser1);
+          const isDeleteable = crowi.pageService.canDeleteCompletely(canDeleteCompletelyTestPage, testUser1, false, userRelatedGroups);
+          expect(isDeleteable).toBe(true);
+        });
+      });
 
-      expect(deleteManyBookmarkSpy).toHaveBeenCalledWith({ page: { $in: [parentForDeleteCompletely._id] } });
-      expect(deleteManyCommentSpy).toHaveBeenCalledWith({ page: { $in: [parentForDeleteCompletely._id] } });
-      expect(deleteManyPageTagRelationSpy).toHaveBeenCalledWith({ relatedPage: { $in: [parentForDeleteCompletely._id] } });
-      expect(deleteManyShareLinkSpy).toHaveBeenCalledWith({ relatedPage: { $in: [parentForDeleteCompletely._id] } });
-      expect(deleteManyRevisionSpy).toHaveBeenCalledWith({ pageId: { $in: [parentForDeleteCompletely._id] } });
-      expect(deleteManyPageSpy).toHaveBeenCalledWith({ _id: { $in: [parentForDeleteCompletely._id] } });
-      expect(removeAllAttachmentsSpy).toHaveBeenCalled();
+      describe(`when user is author,
+        pageCompleteDeletionAuthority is 'anyone',
+        user is not related to all granted groups,
+        and isAllGroupMembershipRequiredForPageCompleteDeletion is true`, () => {
+        beforeEach(async() => {
+          const config = {
+            'security:isAllGroupMembershipRequiredForPageCompleteDeletion': false,
+            'security:pageCompleteDeletionAuthority': PageSingleDeleteCompConfigValue.Anyone,
+            'security:pageRecursiveCompleteDeletionAuthority': PageRecursiveDeleteCompConfigValue.Anyone,
+          };
+          await crowi.configManager.updateConfigsInTheSameNamespace('crowi', config);
+        });
+
+        test('is deletable', async() => {
+          const userRelatedGroups = await crowi.pageGrantService.getUserRelatedGroups(testUser2);
+          const isDeleteable = crowi.pageService.canDeleteCompletely(canDeleteCompletelyTestPage, testUser2, false, userRelatedGroups);
+          expect(isDeleteable).toBe(true);
+        });
+      });
     });
 
-    test('delete completely without options', async() => {
-      await crowi.pageService.deleteCompletely(parentForDeleteCompletely, testUser2, { }, false, false, {
-        ip: '::ffff:127.0.0.1',
-        endpoint: '/_api/v3/pages/deletecompletely',
+    describe('actual delete process', () => {
+      let pageEventSpy;
+      let deleteCompletelyOperationSpy;
+      let deleteCompletelyDescendantsWithStreamSpy;
+
+      let deleteManyBookmarkSpy;
+      let deleteManyCommentSpy;
+      let deleteManyPageTagRelationSpy;
+      let deleteManyShareLinkSpy;
+      let deleteManyRevisionSpy;
+      let deleteManyPageSpy;
+      let removeAllAttachmentsSpy;
+
+      beforeEach(async() => {
+        pageEventSpy = jest.spyOn(crowi.pageService.pageEvent, 'emit');
+        deleteCompletelyOperationSpy = jest.spyOn(crowi.pageService, 'deleteCompletelyOperation');
+        deleteCompletelyDescendantsWithStreamSpy = jest.spyOn(crowi.pageService, 'deleteCompletelyDescendantsWithStream').mockImplementation();
+
+        deleteManyBookmarkSpy = jest.spyOn(Bookmark, 'deleteMany').mockImplementation();
+        deleteManyCommentSpy = jest.spyOn(Comment, 'deleteMany').mockImplementation();
+        deleteManyPageTagRelationSpy = jest.spyOn(PageTagRelation, 'deleteMany').mockImplementation();
+        deleteManyShareLinkSpy = jest.spyOn(ShareLink, 'deleteMany').mockImplementation();
+        deleteManyRevisionSpy = jest.spyOn(Revision, 'deleteMany').mockImplementation();
+        deleteManyPageSpy = jest.spyOn(Page, 'deleteMany').mockImplementation();
+        removeAllAttachmentsSpy = jest.spyOn(crowi.attachmentService, 'removeAllAttachments').mockImplementation();
       });
 
-      expect(deleteCompletelyOperationSpy).toHaveBeenCalled();
-      expect(deleteCompletelyDescendantsWithStreamSpy).not.toHaveBeenCalled();
+      test('deleteCompletelyOperation', async() => {
+        await crowi.pageService.deleteCompletelyOperation([parentForDeleteCompletely._id], [parentForDeleteCompletely.path], { });
 
-      expect(pageEventSpy).toHaveBeenCalledWith('deleteCompletely', parentForDeleteCompletely, testUser2);
-    });
+        expect(deleteManyBookmarkSpy).toHaveBeenCalledWith({ page: { $in: [parentForDeleteCompletely._id] } });
+        expect(deleteManyCommentSpy).toHaveBeenCalledWith({ page: { $in: [parentForDeleteCompletely._id] } });
+        expect(deleteManyPageTagRelationSpy).toHaveBeenCalledWith({ relatedPage: { $in: [parentForDeleteCompletely._id] } });
+        expect(deleteManyShareLinkSpy).toHaveBeenCalledWith({ relatedPage: { $in: [parentForDeleteCompletely._id] } });
+        expect(deleteManyRevisionSpy).toHaveBeenCalledWith({ pageId: { $in: [parentForDeleteCompletely._id] } });
+        expect(deleteManyPageSpy).toHaveBeenCalledWith({ _id: { $in: [parentForDeleteCompletely._id] } });
+        expect(removeAllAttachmentsSpy).toHaveBeenCalled();
+      });
 
+      test('delete completely without options', async() => {
+        await crowi.pageService.deleteCompletely(parentForDeleteCompletely, testUser2, { }, false, false, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/deletecompletely',
+        });
 
-    test('delete completely with isRecursively', async() => {
-      await crowi.pageService.deleteCompletely(parentForDeleteCompletely, testUser2, { }, true, false, {
-        ip: '::ffff:127.0.0.1',
-        endpoint: '/_api/v3/pages/deletecompletely',
+        expect(deleteCompletelyOperationSpy).toHaveBeenCalled();
+        expect(deleteCompletelyDescendantsWithStreamSpy).not.toHaveBeenCalled();
+
+        expect(pageEventSpy).toHaveBeenCalledWith('deleteCompletely', parentForDeleteCompletely, testUser2);
       });
 
-      expect(deleteCompletelyOperationSpy).toHaveBeenCalled();
-      expect(deleteCompletelyDescendantsWithStreamSpy).toHaveBeenCalled();
 
-      expect(pageEventSpy).toHaveBeenCalledWith('deleteCompletely', parentForDeleteCompletely, testUser2);
+      test('delete completely with isRecursively', async() => {
+        await crowi.pageService.deleteCompletely(parentForDeleteCompletely, testUser2, { }, true, false, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/deletecompletely',
+        });
+
+        expect(deleteCompletelyOperationSpy).toHaveBeenCalled();
+        expect(deleteCompletelyDescendantsWithStreamSpy).toHaveBeenCalled();
+
+        expect(pageEventSpy).toHaveBeenCalledWith('deleteCompletely', parentForDeleteCompletely, testUser2);
+      });
     });
   });
 

+ 114 - 9
apps/app/test/integration/service/v5.non-public-page.test.ts

@@ -57,6 +57,9 @@ describe('PageService page operations with non-public pages', () => {
   const pageIdDuplicate4 = new mongoose.Types.ObjectId();
   const pageIdDuplicate5 = new mongoose.Types.ObjectId();
   const pageIdDuplicate6 = new mongoose.Types.ObjectId();
+  const pageIdDuplicate7 = new mongoose.Types.ObjectId();
+  const pageIdDuplicate8 = new mongoose.Types.ObjectId();
+  const pageIdDuplicate9 = new mongoose.Types.ObjectId();
   // revision id
   const revisionIdDuplicate1 = new mongoose.Types.ObjectId();
   const revisionIdDuplicate2 = new mongoose.Types.ObjectId();
@@ -64,6 +67,9 @@ describe('PageService page operations with non-public pages', () => {
   const revisionIdDuplicate4 = new mongoose.Types.ObjectId();
   const revisionIdDuplicate5 = new mongoose.Types.ObjectId();
   const revisionIdDuplicate6 = new mongoose.Types.ObjectId();
+  const revisionIdDuplicate7 = new mongoose.Types.ObjectId();
+  const revisionIdDuplicate8 = new mongoose.Types.ObjectId();
+  const revisionIdDuplicate9 = new mongoose.Types.ObjectId();
 
   /**
    * Revert
@@ -507,7 +513,10 @@ describe('PageService page operations with non-public pages', () => {
         _id: pageIdDuplicate2,
         path: '/np_duplicate2',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroups: [{ item: groupIdA, type: GroupType.userGroup }, { item: externalGroupIdA, type: GroupType.externalUserGroup }],
+        grantedGroups: [
+          { item: groupIdA, type: GroupType.userGroup },
+          { item: externalGroupIdA, type: GroupType.externalUserGroup },
+        ],
         creator: npDummyUser1._id,
         lastUpdateUser: npDummyUser1._id,
         revision: revisionIdDuplicate2,
@@ -517,7 +526,10 @@ describe('PageService page operations with non-public pages', () => {
         _id: pageIdDuplicate3,
         path: '/np_duplicate2/np_duplicate3',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroups: [{ item: groupIdB, type: GroupType.userGroup }, { item: externalGroupIdB, type: GroupType.externalUserGroup }],
+        grantedGroups: [
+          { item: groupIdB, type: GroupType.userGroup },
+          { item: externalGroupIdB, type: GroupType.externalUserGroup },
+        ],
         creator: npDummyUser2._id,
         lastUpdateUser: npDummyUser2._id,
         revision: revisionIdDuplicate3,
@@ -549,6 +561,44 @@ describe('PageService page operations with non-public pages', () => {
         parent: pageIdDuplicate4,
         revision: revisionIdDuplicate6,
       },
+      {
+        _id: pageIdDuplicate7,
+        path: '/np_duplicate7',
+        grant: Page.GRANT_USER_GROUP,
+        creator: npDummyUser1._id,
+        lastUpdateUser: npDummyUser1._id,
+        parent: rootPage._id,
+        revision: revisionIdDuplicate7,
+        grantedGroups: [
+          { item: groupIdA, type: GroupType.userGroup },
+          { item: externalGroupIdA, type: GroupType.externalUserGroup },
+          { item: groupIdB, type: GroupType.userGroup },
+          { item: externalGroupIdB, type: GroupType.externalUserGroup },
+        ],
+      },
+      {
+        _id: pageIdDuplicate8,
+        path: '/np_duplicate7/np_duplicate8',
+        grant: Page.GRANT_USER_GROUP,
+        creator: npDummyUser3._id,
+        lastUpdateUser: npDummyUser3._id,
+        parent: pageIdDuplicate7,
+        revision: revisionIdDuplicate8,
+        grantedGroups: [
+          { item: groupIdC, type: GroupType.userGroup },
+          { item: externalGroupIdC, type: GroupType.externalUserGroup },
+        ],
+      },
+      {
+        _id: pageIdDuplicate9,
+        path: '/np_duplicate7/np_duplicate9',
+        grant: Page.GRANT_OWNER,
+        creator: npDummyUser2._id,
+        lastUpdateUser: npDummyUser2._id,
+        parent: pageIdDuplicate7,
+        revision: revisionIdDuplicate9,
+        grantedUsers: [npDummyUser2._id],
+      },
     ]);
     await Revision.insertMany([
       {
@@ -593,6 +643,27 @@ describe('PageService page operations with non-public pages', () => {
         pageId: pageIdDuplicate6,
         author: npDummyUser1._id,
       },
+      {
+        _id: revisionIdDuplicate7,
+        body: 'np_duplicate7',
+        format: 'markdown',
+        pageId: pageIdDuplicate7,
+        author: npDummyUser1._id,
+      },
+      {
+        _id: revisionIdDuplicate8,
+        body: 'np_duplicate8',
+        format: 'markdown',
+        pageId: pageIdDuplicate8,
+        author: npDummyUser3._id,
+      },
+      {
+        _id: revisionIdDuplicate9,
+        body: 'np_duplicate9',
+        format: 'markdown',
+        pageId: pageIdDuplicate9,
+        author: npDummyUser2._id,
+      },
     ]);
 
     /**
@@ -1083,10 +1154,10 @@ describe('PageService page operations with non-public pages', () => {
   });
   describe('Duplicate', () => {
 
-    const duplicate = async(page, newPagePath, user, isRecursively) => {
+    const duplicate = async(page, newPagePath, user, isRecursively, onlyDuplicateUserRelatedResources) => {
       // mock return value
       const mockedDuplicateRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'duplicateRecursivelyMainOperation').mockReturnValue(null);
-      const duplicatedPage = await crowi.pageService.duplicate(page, newPagePath, user, isRecursively);
+      const duplicatedPage = await crowi.pageService.duplicate(page, newPagePath, user, isRecursively, onlyDuplicateUserRelatedResources);
 
       // retrieve the arguments passed when calling method duplicateRecursivelyMainOperation inside duplicate method
       const argsForDuplicateRecursivelyMainOperation = mockedDuplicateRecursivelyMainOperation.mock.calls[0];
@@ -1108,7 +1179,7 @@ describe('PageService page operations with non-public pages', () => {
       expect(_revision).toBeTruthy();
 
       const newPagePath = '/dup_np_duplicate1';
-      await duplicate(_page, newPagePath, npDummyUser1, false);
+      await duplicate(_page, newPagePath, npDummyUser1, false, false);
 
       const duplicatedPage = await Page.findOne({ path: newPagePath });
       const duplicatedRevision = await Revision.findOne({ pageId: duplicatedPage._id });
@@ -1126,9 +1197,9 @@ describe('PageService page operations with non-public pages', () => {
       const _path1 = '/np_duplicate2';
       const _path2 = '/np_duplicate2/np_duplicate3';
       const _page1 = await Page.findOne({ path: _path1, parent: rootPage._id, grantedGroups: { $elemMatch: { item: groupIdA } } })
-        .populate({ path: 'revision', model: 'Revision', grantedPage: groupIdA._id });
+        .populate({ path: 'revision', model: 'Revision' });
       const _page2 = await Page.findOne({ path: _path2, parent: _page1._id, grantedGroups: { $elemMatch: { item: groupIdB } } })
-        .populate({ path: 'revision', model: 'Revision', grantedPage: groupIdB._id });
+        .populate({ path: 'revision', model: 'Revision' });
       const _revision1 = _page1.revision;
       const _revision2 = _page2.revision;
       expect(_page1).toBeTruthy();
@@ -1137,7 +1208,7 @@ describe('PageService page operations with non-public pages', () => {
       expect(_revision2).toBeTruthy();
 
       const newPagePath = '/dup_np_duplicate2';
-      await duplicate(_page1, newPagePath, npDummyUser2, true);
+      await duplicate(_page1, newPagePath, npDummyUser2, true, false);
 
       const duplicatedPage1 = await Page.findOne({ path: newPagePath }).populate({ path: 'revision', model: 'Revision' });
       const duplicatedPage2 = await Page.findOne({ path: '/dup_np_duplicate2/np_duplicate3' }).populate({ path: 'revision', model: 'Revision' });
@@ -1181,7 +1252,7 @@ describe('PageService page operations with non-public pages', () => {
       expect(baseRevision2).toBeTruthy();
 
       const newPagePath = '/dup_np_duplicate4';
-      await duplicate(_page1, newPagePath, npDummyUser1, true);
+      await duplicate(_page1, newPagePath, npDummyUser1, true, false);
 
       const duplicatedPage1 = await Page.findOne({ path: newPagePath }).populate({ path: 'revision', model: 'Revision' });
       const duplicatedPage2 = await Page.findOne({ path: '/dup_np_duplicate4/np_duplicate5' }).populate({ path: 'revision', model: 'Revision' });
@@ -1203,6 +1274,40 @@ describe('PageService page operations with non-public pages', () => {
       expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id);
       expect(duplicatedRevision3.pageId).toStrictEqual(duplicatedPage3._id);
     });
+    test('Should duplicate only user related resources when onlyDuplicateUserRelatedResources is true', async() => {
+      const _path1 = '/np_duplicate7';
+      const _path2 = '/np_duplicate7/np_duplicate8';
+      const _path3 = '/np_duplicate7/np_duplicate9';
+      const _page1 = await Page.findOne({ path: _path1, parent: rootPage._id })
+        .populate({ path: 'revision', model: 'Revision' });
+      const _page2 = await Page.findOne({ path: _path2, parent: _page1._id });
+      const _page3 = await Page.findOne({ path: _path3, parent: _page1._id });
+      const _revision1 = _page1.revision;
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+      expect(_page3).toBeTruthy();
+      expect(_revision1).toBeTruthy();
+
+      const newPagePath = '/dup_np_duplicate7';
+      await duplicate(_page1, newPagePath, npDummyUser1, true, true);
+
+      const duplicatedPage1 = await Page.findOne({ path: newPagePath }).populate({ path: 'revision', model: 'Revision' });
+      const duplicatedPage2 = await Page.findOne({ path: '/dup_np_duplicate7/np_duplicate8' }).populate({ path: 'revision', model: 'Revision' });
+      const duplicatedPage3 = await Page.findOne({ path: '/dup_np_duplicate7/np_duplicate9' }).populate({ path: 'revision', model: 'Revision' });
+      const duplicatedRevision1 = duplicatedPage1.revision;
+      expect(xssSpy).toHaveBeenCalled();
+      expect(duplicatedPage1).toBeTruthy();
+      expect(duplicatedPage2).toBeFalsy();
+      expect(duplicatedPage3).toBeFalsy();
+      expect(duplicatedRevision1).toBeTruthy();
+      expect(normalizeGrantedGroups(duplicatedPage1.grantedGroups)).toStrictEqual([
+        { item: groupIdA, type: GroupType.userGroup },
+        { item: externalGroupIdA, type: GroupType.externalUserGroup },
+      ]);
+      expect(duplicatedPage1.parent).toStrictEqual(_page1.parent);
+      expect(duplicatedRevision1.body).toBe(_revision1.body);
+      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id);
+    });
 
   });
   describe('Delete', () => {

+ 4 - 4
apps/app/test/integration/service/v5.page.test.ts

@@ -370,7 +370,7 @@ describe('Test page service methods', () => {
           status: 'published',
           grant: 1,
           grantedUsers: [],
-          grantedGroups: null,
+          grantedGroups: [],
           creator: dummyUser1._id,
           lastUpdateUser: dummyUser1._id,
         },
@@ -399,7 +399,7 @@ describe('Test page service methods', () => {
           status: 'published',
           grant: 1,
           grantedUsers: [],
-          grantedGroups: null,
+          grantedGroups: [],
           creator: dummyUser1._id,
           lastUpdateUser: dummyUser1._id,
         },
@@ -428,7 +428,7 @@ describe('Test page service methods', () => {
           status: 'published',
           grant: 1,
           grantedUsers: [],
-          grantedGroups: null,
+          grantedGroups: [],
           creator: dummyUser1._id,
           lastUpdateUser: dummyUser1._id,
         },
@@ -457,7 +457,7 @@ describe('Test page service methods', () => {
           status: 'published',
           grant: Page.GRANT_PUBLIC,
           grantedUsers: [],
-          grantedGroups: null,
+          grantedGroups: [],
           creator: dummyUser1._id,
           lastUpdateUser: dummyUser1._id,
         },

+ 1 - 1
apps/app/test/integration/service/v5.public-page.test.ts

@@ -453,7 +453,7 @@ describe('PageService page operations with only public pages', () => {
           status: 'published',
           grant: 1,
           grantedUsers: [],
-          grantedGroups: null,
+          grantedGroups: [],
           creator: dummyUser1._id,
           lastUpdateUser: dummyUser1._id,
         },

+ 0 - 5
packages/core/src/interfaces/page.ts

@@ -64,11 +64,6 @@ export const PageGrant = {
 type UnionPageGrantKeys = keyof typeof PageGrant;
 export type PageGrant = typeof PageGrant[UnionPageGrantKeys];
 
-/**
- * Neither pages with grant `GRANT_RESTRICTED` nor `GRANT_SPECIFIED` can be on a page tree.
- */
-export type PageGrantCanBeOnTree = typeof PageGrant[Exclude<UnionPageGrantKeys, 'GRANT_RESTRICTED' | 'GRANT_SPECIFIED'>];
-
 export const PageStatus = {
   STATUS_PUBLISHED: 'published',
   STATUS_DELETED: 'deleted',