Răsfoiți Sursa

Merge pull request #7895 from weseek/feat/124385-126902-make-granted-group-ref-dynamic

Feat/124385 126902 make granted group ref dynamic
Futa Arai 2 ani în urmă
părinte
comite
275ab333a1
51 a modificat fișierele cu 1411 adăugiri și 527 ștergeri
  1. 1 1
      apps/app/resource/search/mappings-es7.json
  2. 1 1
      apps/app/resource/search/mappings-es8-for-ci.json
  3. 1 1
      apps/app/resource/search/mappings-es8.json
  4. 8 2
      apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts
  5. 8 2
      apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts
  6. 6 5
      apps/app/src/components/PageAlert/FixPageGrantAlert.tsx
  7. 6 1
      apps/app/src/components/PageAlert/PageGrantAlert.tsx
  8. 4 3
      apps/app/src/components/PageEditor.tsx
  9. 4 2
      apps/app/src/components/PageEditorByHackmd.tsx
  10. 2 3
      apps/app/src/components/SavePageControls.tsx
  11. 13 11
      apps/app/src/components/SavePageControls/GrantSelector.tsx
  12. 1 1
      apps/app/src/components/Sidebar/PageTree/Item.tsx
  13. 42 0
      apps/app/src/features/external-user-group/server/models/external-user-group-relation.integ.ts
  14. 15 0
      apps/app/src/features/external-user-group/server/models/external-user-group-relation.ts
  15. 2 0
      apps/app/src/features/external-user-group/server/models/external-user-group.ts
  16. 7 1
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  17. 3 1
      apps/app/src/interfaces/page-grant.ts
  18. 3 2
      apps/app/src/interfaces/page-operation.ts
  19. 5 4
      apps/app/src/interfaces/page.ts
  20. 160 0
      apps/app/src/migrations/20230723061824-granted-group-to-array-of-objects.js
  21. 10 7
      apps/app/src/pages/[[...path]].page.tsx
  22. 4 4
      apps/app/src/server/models/interfaces/page-operation.ts
  23. 48 49
      apps/app/src/server/models/obsolete-page.js
  24. 23 2
      apps/app/src/server/models/page-operation.ts
  25. 46 15
      apps/app/src/server/models/page.ts
  26. 16 8
      apps/app/src/server/models/user-group-relation.ts
  27. 0 1
      apps/app/src/server/routes/admin.js
  28. 33 20
      apps/app/src/server/routes/apiv3/page.js
  29. 2 2
      apps/app/src/server/routes/apiv3/pages.js
  30. 1 2
      apps/app/src/server/routes/apiv3/user-group-relation.js
  31. 14 3
      apps/app/src/server/routes/apiv3/user-group.js
  32. 3 1
      apps/app/src/server/routes/apiv3/users.js
  33. 2 2
      apps/app/src/server/routes/me.js
  34. 5 5
      apps/app/src/server/routes/page.js
  35. 6 5
      apps/app/src/server/routes/search.ts
  36. 126 67
      apps/app/src/server/service/page-grant.ts
  37. 72 65
      apps/app/src/server/service/page.ts
  38. 8 6
      apps/app/src/server/service/search-delegator/elasticsearch.ts
  39. 3 2
      apps/app/src/server/service/search.ts
  40. 5 6
      apps/app/src/server/service/user-group.ts
  41. 51 0
      apps/app/src/server/util/compare-objectId.spec.ts
  42. 22 3
      apps/app/src/server/util/compare-objectId.ts
  43. 27 0
      apps/app/src/server/util/granted-group.ts
  44. 1 1
      apps/app/test/integration/models/page.test.js
  45. 337 62
      apps/app/test/integration/models/v5.page.test.js
  46. 32 32
      apps/app/test/integration/service/page-grant.test.js
  47. 35 21
      apps/app/test/integration/service/v5.migration.test.js
  48. 169 71
      apps/app/test/integration/service/v5.non-public-page.test.ts
  49. 4 19
      apps/app/test/integration/service/v5.page.test.ts
  50. 1 1
      apps/app/test/integration/service/v5.public-page.test.ts
  51. 13 4
      packages/core/src/interfaces/page.ts

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

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

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

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

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

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

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

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

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

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

+ 6 - 5
apps/app/src/components/PageAlert/FixPageGrantAlert.tsx

@@ -1,6 +1,6 @@
 import React, { useEffect, useState, useCallback } from 'react';
 
-import { PageGrant } from '@growi/core';
+import { GroupType, PageGrant } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
@@ -10,6 +10,7 @@ import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { IPageGrantData } from '~/interfaces/page';
 import { IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
+import { UserGroupDocument } from '~/server/models/user-group';
 import { useCurrentUser } from '~/stores/context';
 import { useSWRxApplicableGrant, useSWRxIsGrantNormalized, useSWRxCurrentPage } from '~/stores/page';
 
@@ -29,7 +30,7 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   } = props;
 
   const [selectedGrant, setSelectedGrant] = useState<PageGrant>(PageGrant.GRANT_RESTRICTED);
-  const [selectedGroup, setSelectedGroup] = useState<{_id: string, name: string} | undefined>(undefined); // TODO: Typescriptize model
+  const [selectedGroup, setSelectedGroup] = useState<UserGroupDocument | undefined>(undefined);
 
   // Alert message state
   const [shouldShowModalAlert, setShowModalAlert] = useState<boolean>(false);
@@ -57,7 +58,7 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
     try {
       await apiv3Put(`/page/${pageId}/grant`, {
         grant: selectedGrant,
-        grantedGroup: selectedGroup?._id,
+        grantedGroups: selectedGroup?._id != null ? [{ item: selectedGroup?._id, type: GroupType.userGroup }] : null,
       });
 
       toastSuccess(t('Successfully updated'));
@@ -86,10 +87,10 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
     }
 
     if (grantData.grant === 5) {
-      if (grantData.grantedGroup == null) {
+      if (grantData.grantedGroups == null || grantData.grantedGroups.length === 0) {
         return t('fix_page_grant.modal.grant_label.isForbidden');
       }
-      return `${t('fix_page_grant.modal.radio_btn.grant_group')}: (${grantData.grantedGroup.name})`;
+      return `${t('fix_page_grant.modal.radio_btn.grant_group')}: (${grantData.grantedGroups[0].name})`;
     }
 
     throw Error('cannot get grant label'); // this error can't be throwed

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

@@ -1,5 +1,6 @@
 import React from 'react';
 
+import { isPopulated } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 
 import { useSWRxCurrentPage } from '~/stores/page';
@@ -32,7 +33,11 @@ export const PageGrantAlert = (): JSX.Element => {
       if (pageData.grant === 5) {
         return (
           <>
-            <i className="icon-fw icon-organization"></i><strong>{pageData.grantedGroup.name}</strong>
+            <i className="icon-fw icon-organization"></i>
+            <strong>{
+              isPopulated(pageData.grantedGroups[0].item) && pageData.grantedGroups[0].item.name
+            }
+            </strong>
           </>
         );
       }

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

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

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

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

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

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

+ 13 - 11
apps/app/src/components/SavePageControls/GrantSelector.tsx

@@ -1,7 +1,7 @@
 import React, { useCallback, useState } from 'react';
 
 import { isPopulated } from '@growi/core';
-import type { IUserGroupHasId } from '@growi/core';
+import type { GroupType, IUserGroupHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import {
   UncontrolledDropdown,
@@ -35,8 +35,11 @@ const AVAILABLE_GRANTS = [
 type Props = {
   disabled?: boolean,
   grant: number,
-  grantGroupId?: string,
-  grantGroupName?: string,
+  grantedGroups?: {
+    id: string,
+    name: string,
+    type: GroupType,
+  }[]
 
   onUpdateGrant?: (grantData: IPageGrantData) => void,
 }
@@ -49,10 +52,9 @@ const GrantSelector = (props: Props): JSX.Element => {
 
   const {
     disabled,
-    grantGroupName,
+    grantedGroups,
     onUpdateGrant,
     grant: currentGrant,
-    grantGroupId,
   } = props;
 
 
@@ -79,13 +81,13 @@ const GrantSelector = (props: Props): JSX.Element => {
     }
 
     if (onUpdateGrant != null) {
-      onUpdateGrant({ grant, grantedGroup: undefined });
+      onUpdateGrant({ grant, grantedGroups: undefined });
     }
   }, [onUpdateGrant, showSelectGroupModal]);
 
   const groupListItemClickHandler = useCallback((grantGroup: IUserGroupHasId) => {
     if (onUpdateGrant != null) {
-      onUpdateGrant({ grant: 5, grantedGroup: { id: grantGroup._id, name: grantGroup.name } });
+      onUpdateGrant({ grant: 5, grantedGroups: [{ id: grantGroup._id, name: grantGroup.name, type: 'UserGroup' }] });
     }
 
     // hide modal
@@ -101,7 +103,7 @@ const GrantSelector = (props: Props): JSX.Element => {
     let dropdownToggleLabelElm;
 
     const dropdownMenuElems = AVAILABLE_GRANTS.map((opt) => {
-      const label = ((opt.grant === 5 && opt.reselectLabel != null) && grantGroupId != null)
+      const label = ((opt.grant === 5 && opt.reselectLabel != null) && grantedGroups != null && grantedGroups.length > 0)
         ? opt.reselectLabel // when grantGroup is selected
         : opt.label;
 
@@ -122,11 +124,11 @@ const GrantSelector = (props: Props): JSX.Element => {
     });
 
     // add specified group option
-    if (grantGroupId != null) {
+    if (grantedGroups != null && grantedGroups.length > 0) {
       const labelElm = (
         <span>
           <i className="icon icon-fw icon-organization"></i>
-          <span className="label">{grantGroupName}</span>
+          <span className="label">{grantedGroups[0].name}</span>
         </span>
       );
 
@@ -148,7 +150,7 @@ const GrantSelector = (props: Props): JSX.Element => {
         </UncontrolledDropdown>
       </div>
     );
-  }, [changeGrantHandler, currentGrant, disabled, grantGroupId, grantGroupName, t]);
+  }, [changeGrantHandler, currentGrant, disabled, grantedGroups, t]);
 
   /**
    * Render select grantgroup modal.

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

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

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

@@ -18,10 +18,17 @@ describe('ExternalUserGroupRelation model', () => {
   let user1;
   const userId1 = new mongoose.Types.ObjectId();
 
+  let user2;
+  const userId2 = new mongoose.Types.ObjectId();
+
   beforeAll(async() => {
     user1 = await User.create({
       _id: userId1, name: 'user1', username: 'user1', email: 'user1@example.com',
     });
+
+    user2 = await User.create({
+      _id: userId2, name: 'user2', username: 'user2', email: 'user2@example.com',
+    });
   });
 
   afterEach(async() => {
@@ -72,4 +79,39 @@ describe('ExternalUserGroupRelation model', () => {
       expect(relationsAfterRemoval.length).toBe(0);
     });
   });
+
+  describe('findAllUserIdsForUserGroups', () => {
+    const groupId1 = new mongoose.Types.ObjectId();
+    const groupId2 = new mongoose.Types.ObjectId();
+    const groupId3 = new mongoose.Types.ObjectId();
+
+    beforeAll(async() => {
+      await ExternalUserGroupRelation.createRelations([groupId1, groupId2], user1);
+      await ExternalUserGroupRelation.create({ relatedGroup: groupId3, relatedUser: user2._id });
+    });
+
+    it('finds all unique user ids for specified user groups', async() => {
+      const userIds = await ExternalUserGroupRelation.findAllUserIdsForUserGroups([groupId1, groupId2, groupId3]);
+      expect(userIds).toStrictEqual([userId1.toString(), user2._id.toString()]);
+    });
+  });
+
+  describe('findAllUserGroupIdsRelatedToUser', () => {
+    const groupId1 = new mongoose.Types.ObjectId();
+    const groupId2 = new mongoose.Types.ObjectId();
+    const groupId3 = new mongoose.Types.ObjectId();
+
+    beforeAll(async() => {
+      await ExternalUserGroupRelation.createRelations([groupId1, groupId2], user1);
+      await ExternalUserGroupRelation.create({ relatedGroup: groupId3, relatedUser: user2._id });
+    });
+
+    it('finds all group ids related to user', async() => {
+      const groupIds = await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user1);
+      expect(groupIds).toStrictEqual([groupId1, groupId2]);
+
+      const groupIds2 = await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user2);
+      expect(groupIds2).toStrictEqual([groupId3]);
+    });
+  });
 });

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

@@ -1,5 +1,6 @@
 import { Schema, Model, Document } from 'mongoose';
 
+import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 import UserGroupRelation from '~/server/models/user-group-relation';
 
 import { getOrCreateModel } from '../../../../server/util/mongoose-utils';
@@ -15,6 +16,12 @@ export interface ExternalUserGroupRelationModel extends Model<ExternalUserGroupR
   PAGE_ITEMS: 50,
 
   removeAllByUserGroups: (groupsToDelete: ExternalUserGroupDocument[]) => Promise<any>,
+
+  findAllUserIdsForUserGroups: (userGroupIds: ObjectIdLike[]) => Promise<string[]>,
+
+  findGroupsWithDescendantsByGroupAndUser: (group: ExternalUserGroupDocument, user) => Promise<ExternalUserGroupDocument[]>,
+
+  countByGroupIdsAndUser: (userGroupIds: ObjectIdLike[], userData) => Promise<number>
 }
 
 const schema = new Schema<ExternalUserGroupRelationDocument, ExternalUserGroupRelationModel>({
@@ -32,4 +39,12 @@ schema.statics.findAllRelation = UserGroupRelation.findAllRelation;
 
 schema.statics.removeAllInvalidRelations = UserGroupRelation.removeAllInvalidRelations;
 
+schema.statics.findGroupsWithDescendantsByGroupAndUser = UserGroupRelation.findGroupsWithDescendantsByGroupAndUser;
+
+schema.statics.countByGroupIdsAndUser = UserGroupRelation.countByGroupIdsAndUser;
+
+schema.statics.findAllUserIdsForUserGroups = UserGroupRelation.findAllUserIdsForUserGroups;
+
+schema.statics.findAllUserGroupIdsRelatedToUser = UserGroupRelation.findAllUserGroupIdsRelatedToUser;
+
 export default getOrCreateModel<ExternalUserGroupRelationDocument, ExternalUserGroupRelationModel>('ExternalUserGroupRelation', schema);

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

@@ -57,4 +57,6 @@ schema.statics.findGroupsWithAncestorsRecursively = UserGroup.findGroupsWithAnce
 
 schema.statics.findGroupsWithDescendantsRecursively = UserGroup.findGroupsWithDescendantsRecursively;
 
+schema.statics.findGroupsWithDescendantsById = UserGroup.findGroupsWithDescendantsById;
+
 export default getOrCreateModel<ExternalUserGroupDocument, ExternalUserGroupModel>('ExternalUserGroup', schema);

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

@@ -1,3 +1,4 @@
+import { GroupType } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { Router, Request } from 'express';
 import {
@@ -137,9 +138,14 @@ module.exports = (crowi: Crowi): Router => {
       const { id: deleteGroupId } = req.params;
       const { actionName, transferToUserGroupId } = req.query;
 
+      const transferGroupInfo = transferToUserGroupId != null ? {
+        item: transferToUserGroupId as string,
+        type: GroupType.externalUserGroup,
+      } : undefined;
+
       try {
         const userGroups = await (crowi.userGroupService as UserGroupService)
-          .removeCompletelyByRootGroupId(deleteGroupId, actionName, transferToUserGroupId, req.user, ExternalUserGroup, ExternalUserGroupRelation);
+          .removeCompletelyByRootGroupId(deleteGroupId, actionName, req.user, transferGroupInfo, ExternalUserGroup, ExternalUserGroupRelation);
 
         const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_DELETE };
         activityEvent.emit('update', res.locals.activity._id, parameters);

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

@@ -1,9 +1,11 @@
 import { PageGrant } from '@growi/core';
 
+import { UserGroupDocument } from '~/server/models/user-group';
+
 import { IPageGrantData } from './page';
 
 export type IDataApplicableGroup = {
-  applicableGroups?: {_id: string, name: string}[] // TODO: Typescriptize model
+  applicableGroups?: UserGroupDocument[]
 }
 
 export type IDataApplicableGrant = null | IDataApplicableGroup;

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

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

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

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

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

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

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

@@ -417,17 +417,20 @@ class MultiplePagesHitsError extends ExtensibleCustomError {
 
 // apply parent page grant fot creating page
 async function applyGrantToPage(props: Props, ancestor: any) {
-  await ancestor.populate('grantedGroup');
+  await ancestor.populate('grantedGroups.item');
   const grant = {
     grant: ancestor.grant,
   };
-  const grantedGroup = ancestor.grantedGroup ? {
-    grantedGroup: {
-      id: ancestor.grantedGroup.id,
-      name: ancestor.grantedGroup.name,
-    },
+  const grantedGroups = ancestor.grantedGroups ? {
+    grantedGroups: ancestor.grantedGroups.map((group) => {
+      return {
+        id: group.item._id,
+        name: group.item.name,
+        type: group.type,
+      };
+    }),
   } : {};
-  props.grantData = Object.assign(grant, grantedGroup);
+  props.grantData = Object.assign(grant, grantedGroups);
 }
 
 async function injectPageData(context: GetServerSidePropsContext, props: Props): Promise<void> {

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

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

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

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

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

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

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

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

+ 16 - 8
apps/app/src/server/models/user-group-relation.ts

@@ -1,6 +1,7 @@
 import type { IUserGroupRelation } from '@growi/core';
 import mongoose, { Model, Schema, Document } from 'mongoose';
 
+import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 import { UserGroupDocument } from './user-group';
@@ -19,6 +20,12 @@ export interface UserGroupRelationModel extends Model<UserGroupRelationDocument>
   PAGE_ITEMS: 50,
 
   removeAllByUserGroups: (groupsToDelete: UserGroupDocument[]) => Promise<any>,
+
+  findAllUserIdsForUserGroups: (userGroupIds: ObjectIdLike[]) => Promise<string[]>,
+
+  findGroupsWithDescendantsByGroupAndUser: (group: UserGroupDocument, user) => Promise<UserGroupDocument[]>,
+
+  countByGroupIdsAndUser: (userGroupIds: ObjectIdLike[], userData) => Promise<number>
 }
 
 /*
@@ -81,13 +88,14 @@ schema.statics.findAllRelationForUserGroup = function(userGroup) {
     .exec();
 };
 
-schema.statics.findAllUserIdsForUserGroup = async function(userGroup) {
+schema.statics.findAllUserIdsForUserGroups = async function(userGroupIds: ObjectIdLike[]): Promise<string[]> {
   const relations = await this
-    .find({ relatedGroup: userGroup })
+    .find({ relatedGroup: { $in: userGroupIds } })
     .select('relatedUser')
     .exec();
 
-  return relations.map(r => r.relatedUser);
+  // return unique ids
+  return [...new Set(relations.map(r => r.relatedUser.toString()))];
 };
 
 /**
@@ -148,9 +156,9 @@ schema.statics.findAllUserGroupIdsRelatedToUser = async function(user) {
  * @param {User} userData find query param for relatedUser
  * @returns {Promise<number>}
  */
-schema.statics.countByGroupIdAndUser = async function(userGroupId, userData) {
+schema.statics.countByGroupIdsAndUser = async function(userGroupIds: ObjectIdLike[], userData): Promise<number> {
   const query = {
-    relatedGroup: userGroupId,
+    relatedGroup: { $in: userGroupIds },
     relatedUser: userData.id,
   };
 
@@ -310,11 +318,11 @@ schema.statics.createByGroupIdsAndUserIds = async function(groupIds, userIds) {
 /**
  * Recursively finds descendant groups by populating relations.
  * @static
- * @param {UserGroupDocument[]} groups
+ * @param {UserGroupDocument} group
  * @param {UserDocument} user
  * @returns UserGroupDocument[]
  */
-schema.statics.findGroupsWithDescendantsByGroupAndUser = async function(group, user) {
+schema.statics.findGroupsWithDescendantsByGroupAndUser = async function(group: UserGroupDocument, user): Promise<UserGroupDocument[]> {
   const descendantGroups = [group];
 
   const incrementGroupsRecursively = async(groups, user) => {
@@ -328,7 +336,7 @@ schema.statics.findGroupsWithDescendantsByGroupAndUser = async function(group, u
       },
       {
         $lookup: {
-          from: 'usergroups',
+          from: this.collection.collectionName,
           localField: 'relatedGroup',
           foreignField: '_id',
           as: 'relatedGroup',

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

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

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

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

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

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

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

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

+ 14 - 3
apps/app/src/server/routes/apiv3/user-group.js

@@ -1,8 +1,11 @@
+import { GroupType } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 
+
 import { SupportedAction } from '~/interfaces/activity';
 import { serializeUserGroupRelationSecurely } from '~/server/models/serializers/user-group-relation-serializer';
 import UserGroup from '~/server/models/user-group';
+import UserGroupRelation from '~/server/models/user-group-relation';
 import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
 import loggerFactory from '~/utils/logger';
 
@@ -37,7 +40,6 @@ module.exports = (crowi) => {
   const activityEvent = crowi.event('activity');
 
   const {
-    UserGroupRelation,
     User,
     Page,
   } = crowi.models;
@@ -429,8 +431,13 @@ module.exports = (crowi) => {
     const { id: deleteGroupId } = req.params;
     const { actionName, transferToUserGroupId } = req.query;
 
+    const transferGroupInfo = transferToUserGroupId != null ? {
+      item: transferToUserGroupId,
+      type: GroupType.userGroup,
+    } : undefined;
+
     try {
-      const userGroups = await crowi.userGroupService.removeCompletelyByRootGroupId(deleteGroupId, actionName, transferToUserGroupId, req.user);
+      const userGroups = await crowi.userGroupService.removeCompletelyByRootGroupId(deleteGroupId, actionName, req.user, transferGroupInfo);
 
       const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_DELETE };
       activityEvent.emit('update', res.locals.activity._id, parameters);
@@ -809,7 +816,11 @@ module.exports = (crowi) => {
     try {
       const { docs, totalDocs } = await Page.paginate({
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: { $in: [id] },
+        grantedGroups: {
+          $elemMatch: {
+            item: id,
+          },
+        },
       }, {
         offset,
         limit,

+ 3 - 1
apps/app/src/server/routes/apiv3/users.js

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

+ 2 - 2
apps/app/src/server/routes/me.js

@@ -1,3 +1,5 @@
+import UserGroupRelation from '../models/user-group-relation';
+
 /**
  * @swagger
  *
@@ -49,8 +51,6 @@
  */
 
 module.exports = function(crowi, app) {
-  const models = crowi.models;
-  const UserGroupRelation = models.UserGroupRelation;
   const ApiResponse = require('../util/apiResponse');
 
   // , pluginService = require('../service/plugin')

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

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

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

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

+ 126 - 67
apps/app/src/server/service/page-grant.ts

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

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

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

+ 8 - 6
apps/app/src/server/service/search-delegator/elasticsearch.ts

@@ -366,16 +366,18 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       });
     }
 
-    let grantedGroupId = null;
-    if (page.grantedGroup != null) {
-      const groupId = (page.grantedGroup._id == null) ? page.grantedGroup : page.grantedGroup._id;
-      grantedGroupId = groupId.toString();
+    let grantedGroupIds = null;
+    if (page.grantedGroups != null) {
+      grantedGroupIds = page.grantedGroups.map((group) => {
+        const groupId = (group.item._id == null) ? group.item : group.item._id;
+        return groupId.toString();
+      });
     }
 
     return {
       grant: page.grant,
       granted_users: grantedUserIds,
-      granted_group: grantedGroupId,
+      granted_groups: grantedGroupIds,
     };
   }
 
@@ -882,7 +884,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
           bool: {
             must: [
               { term: { grant: GRANT_USER_GROUP } },
-              { terms: { granted_group: userGroupIds } },
+              { terms: { granted_groups: userGroupIds } },
             ],
           },
         },

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

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

+ 5 - 6
apps/app/src/server/service/user-group.ts

@@ -1,9 +1,9 @@
-import type { IUser } from '@growi/core';
+import type { IUser, GrantedGroup } from '@growi/core';
 import { Model } from 'mongoose';
 
 import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 import UserGroup, { UserGroupDocument, UserGroupModel } from '~/server/models/user-group';
-import { excludeTestIdsFromTargetIds, isIncludesObjectId } from '~/server/util/compare-objectId';
+import { excludeTestIdsFromTargetIds, includesObjectIds } from '~/server/util/compare-objectId';
 import loggerFactory from '~/utils/logger';
 
 import UserGroupRelation, { UserGroupRelationDocument, UserGroupRelationModel } from '../models/user-group-relation';
@@ -78,7 +78,7 @@ class UserGroupService {
 
     // throw if parent was in self and its descendants
     const descendantsWithTarget = await UserGroup.findGroupsWithDescendantsRecursively([userGroup]);
-    if (isIncludesObjectId(descendantsWithTarget.map(d => d._id), parent._id)) {
+    if (includesObjectIds(descendantsWithTarget.map(d => d._id), [parent._id])) {
       throw Error('It is not allowed to choose parent from descendant groups.');
     }
 
@@ -114,7 +114,7 @@ class UserGroupService {
   }
 
   async removeCompletelyByRootGroupId(
-      deleteRootGroupId, action, transferToUserGroupId, user,
+      deleteRootGroupId, action, user, transferToUserGroup?: GrantedGroup,
       userGroupModel: Model<UserGroupDocument> & UserGroupModel = UserGroup,
       userGroupRelationModel: Model<UserGroupRelationDocument> & UserGroupRelationModel = UserGroupRelation,
   ) {
@@ -126,8 +126,7 @@ class UserGroupService {
     const groupsToDelete = await userGroupModel.findGroupsWithDescendantsRecursively([rootGroup]);
 
     // 1. update page & remove all groups
-    // TODO: update pageService logic to handle external user groups (https://redmine.weseek.co.jp/issues/124385)
-    await this.crowi.pageService.handlePrivatePagesForGroupsToDelete(groupsToDelete, action, transferToUserGroupId, user);
+    await this.crowi.pageService.handlePrivatePagesForGroupsToDelete(groupsToDelete, action, transferToUserGroup, user);
     // 2. remove all groups
     const deletedGroups = await userGroupModel.deleteMany({ _id: { $in: groupsToDelete.map(g => g._id) } });
     // 3. remove all relations

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

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

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

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

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

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

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

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

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

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

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

@@ -144,7 +144,7 @@ describe('PageGrantService', () => {
         creator: user1,
         lastUpdateUser: user1,
         grantedUsers: null,
-        grantedGroup: null,
+        grantedGroups: null,
         parent: rootPage._id,
       },
       {
@@ -153,7 +153,7 @@ describe('PageGrantService', () => {
         creator: user1,
         lastUpdateUser: user1,
         grantedUsers: null,
-        grantedGroup: groupParent._id,
+        grantedGroups: [{ item: groupParent._id, type: 'UserGroup' }],
         parent: rootPage._id,
       },
     ]);
@@ -183,7 +183,7 @@ describe('PageGrantService', () => {
         path: v4PageRootOnlyInsideTheGroupPagePath,
         grant: Page.GRANT_USER_GROUP,
         parent: null,
-        grantedGroup: groupParent._id,
+        grantedGroups: [{ item: groupParent._id, type: 'UserGroup' }],
       },
     ]);
 
@@ -262,7 +262,7 @@ describe('PageGrantService', () => {
         creator: user1,
         lastUpdateUser: user1,
         grantedUsers: null,
-        grantedGroup: null,
+        grantedGroups: null,
         parent: emptyPage1._id,
       },
       {
@@ -271,7 +271,7 @@ describe('PageGrantService', () => {
         creator: user1,
         lastUpdateUser: user1,
         grantedUsers: [user1._id],
-        grantedGroup: null,
+        grantedGroups: null,
         parent: emptyPage2._id,
       },
       {
@@ -280,7 +280,7 @@ describe('PageGrantService', () => {
         creator: user1,
         lastUpdateUser: user1,
         grantedUsers: null,
-        grantedGroup: groupParent._id,
+        grantedGroups: [{ item: groupParent._id, type: 'UserGroup' }],
         parent: emptyPage3._id,
       },
       {
@@ -289,7 +289,7 @@ describe('PageGrantService', () => {
         creator: user1,
         lastUpdateUser: user1,
         grantedUsers: null,
-        grantedGroup: groupChild._id,
+        grantedGroups: [{ item: groupChild._id, type: 'UserGroup' }],
         parent: emptyPage3._id,
       },
       {
@@ -298,7 +298,7 @@ describe('PageGrantService', () => {
         creator: user1,
         lastUpdateUser: user1,
         grantedUsers: [user1._id],
-        grantedGroup: null,
+        grantedGroups: null,
         parent: emptyPage3._id,
       },
     ]);
@@ -333,10 +333,10 @@ describe('PageGrantService', () => {
       const targetPath = '/NEW';
       const grant = Page.GRANT_PUBLIC;
       const grantedUserIds = null;
-      const grantedGroupId = null;
+      const grantedGroupIds = null;
       const shouldCheckDescendants = false;
 
-      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
 
       expect(result).toBe(true);
     });
@@ -345,10 +345,10 @@ describe('PageGrantService', () => {
       const targetPath = '/NEW_GroupParent';
       const grant = Page.GRANT_USER_GROUP;
       const grantedUserIds = null;
-      const grantedGroupId = groupParent._id;
+      const grantedGroupIdš = [{ item: groupParent._id, type: 'UserGroup' }];
       const shouldCheckDescendants = false;
 
-      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIdš, shouldCheckDescendants);
 
       expect(result).toBe(true);
     });
@@ -357,10 +357,10 @@ describe('PageGrantService', () => {
       const targetPath = `${pageRootPublicPath}/NEW`;
       const grant = Page.GRANT_PUBLIC;
       const grantedUserIds = null;
-      const grantedGroupId = null;
+      const grantedGroupIds = null;
       const shouldCheckDescendants = false;
 
-      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
 
       expect(result).toBe(true);
     });
@@ -369,10 +369,10 @@ describe('PageGrantService', () => {
       const targetPath = `${pageRootGroupParentPath}/NEW`;
       const grant = Page.GRANT_USER_GROUP;
       const grantedUserIds = null;
-      const grantedGroupId = groupParent._id;
+      const grantedGroupIds = [{ item: groupParent._id, type: 'UserGroup' }];
       const shouldCheckDescendants = false;
 
-      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
 
       expect(result).toBe(true);
     });
@@ -381,10 +381,10 @@ describe('PageGrantService', () => {
       const targetPath = `${pageE1PublicPath}/NEW`;
       const grant = Page.GRANT_PUBLIC;
       const grantedUserIds = null;
-      const grantedGroupId = null;
+      const grantedGroupIds = null;
       const shouldCheckDescendants = false;
 
-      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
 
       expect(result).toBe(true);
     });
@@ -393,10 +393,10 @@ describe('PageGrantService', () => {
       const targetPath = `${pageE2User1Path}/NEW`;
       const grant = Page.GRANT_OWNER;
       const grantedUserIds = [user1._id];
-      const grantedGroupId = null;
+      const grantedGroupIds = null;
       const shouldCheckDescendants = false;
 
-      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
 
       expect(result).toBe(true);
     });
@@ -405,10 +405,10 @@ describe('PageGrantService', () => {
       const targetPath = `${pageE3GroupParentPath}/NEW`;
       const grant = Page.GRANT_PUBLIC;
       const grantedUserIds = null;
-      const grantedGroupId = null;
+      const grantedGroupIds = null;
       const shouldCheckDescendants = false;
 
-      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
 
       expect(result).toBe(false);
     });
@@ -417,10 +417,10 @@ describe('PageGrantService', () => {
       const targetPath = `${pageE3GroupChildPath}/NEW`;
       const grant = Page.GRANT_USER_GROUP;
       const grantedUserIds = null;
-      const grantedGroupId = groupParent._id;
+      const grantedGroupIds = [{ item: groupParent._id, type: 'UserGroup' }];
       const shouldCheckDescendants = false;
 
-      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
 
       expect(result).toBe(false);
     });
@@ -431,10 +431,10 @@ describe('PageGrantService', () => {
       const targetPath = emptyPagePath1;
       const grant = Page.GRANT_PUBLIC;
       const grantedUserIds = null;
-      const grantedGroupId = null;
+      const grantedGroupIds = null;
       const shouldCheckDescendants = true;
 
-      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
 
       expect(result).toBe(true);
     });
@@ -443,10 +443,10 @@ describe('PageGrantService', () => {
       const targetPath = emptyPagePath2;
       const grant = Page.GRANT_OWNER;
       const grantedUserIds = [user1._id];
-      const grantedGroupId = null;
+      const grantedGroupIds = null;
       const shouldCheckDescendants = true;
 
-      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
 
       expect(result).toBe(true);
     });
@@ -455,10 +455,10 @@ describe('PageGrantService', () => {
       const targetPath = emptyPagePath3;
       const grant = Page.GRANT_USER_GROUP;
       const grantedUserIds = null;
-      const grantedGroupId = groupParent._id;
+      const grantedGroupIds = [{ item: groupParent._id, type: 'UserGroup' }];
       const shouldCheckDescendants = true;
 
-      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
 
       expect(result).toBe(true);
     });
@@ -467,10 +467,10 @@ describe('PageGrantService', () => {
       const targetPath = emptyPagePath1;
       const grant = Page.GRANT_OWNER;
       const grantedUserIds = [user1._id];
-      const grantedGroupId = null;
+      const grantedGroupIds = null;
       const shouldCheckDescendants = true;
 
-      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+      const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
 
       expect(result).toBe(false);
     });

+ 35 - 21
apps/app/test/integration/service/v5.migration.test.js

@@ -37,8 +37,22 @@ describe('V5 page migration', () => {
   const onlyPublic = filter => ({ grant: Page.GRANT_PUBLIC, ...filter });
   const ownedByTestUser1 = filter => ({ grant: Page.GRANT_OWNER, grantedUsers: [testUser1._id], ...filter });
   const root = filter => ({ grantedUsers: [rootUser._id], ...filter });
-  const rootUserGroup = filter => ({ grantedGroup: rootUserGroupId, ...filter });
-  const testUser1Group = filter => ({ grantedGroup: testUser1GroupId, ...filter });
+  const rootUserGroup = filter => ({
+    grantedGroups: {
+      $elemMatch: {
+        item: rootUserGroupId,
+      },
+    },
+    ...filter,
+  });
+  const testUser1Group = filter => ({
+    grantedGroups: {
+      $elemMatch: {
+        item: testUser1GroupId,
+      },
+    },
+    ...filter,
+  });
 
   const normalized = { parent: { $ne: null } };
   const notNormalized = { parent: null };
@@ -160,14 +174,14 @@ describe('V5 page migration', () => {
         path: '/normalize_1/normalize_2',
         parent: pageId1,
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdB,
+        grantedGroups: [{ item: groupIdB, type: 'UserGroup' }],
         grantedUsers: [testUser1._id],
       },
       {
         _id: pageId3,
         path: '/normalize_1',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdA,
+        grantedGroups: [{ item: groupIdA, type: 'UserGroup' }],
         grantedUsers: [testUser1._id],
       },
       {
@@ -182,34 +196,34 @@ describe('V5 page migration', () => {
         path: '/normalize_4/normalize_5',
         parent: pageId4,
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdA,
+        grantedGroups: [{ item: groupIdA, type: 'UserGroup' }],
         grantedUsers: [testUser1._id],
       },
       {
         _id: pageId6,
         path: '/normalize_4',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdIsolate,
+        grantedGroups: [{ item: groupIdIsolate, type: 'UserGroup' }],
         grantedUsers: [testUser1._id],
       },
       {
         path: '/normalize_7/normalize_8_gA',
         grant: Page.GRANT_USER_GROUP,
         creator: testUser1,
-        grantedGroup: groupIdA,
+        grantedGroups: [{ item: groupIdA, type: 'UserGroup' }],
         grantedUsers: [testUser1._id],
       },
       {
         path: '/normalize_7/normalize_8_gA/normalize_9_gB',
         grant: Page.GRANT_USER_GROUP,
         creator: testUser1,
-        grantedGroup: groupIdB,
+        grantedGroups: [{ item: groupIdB, type: 'UserGroup' }],
         grantedUsers: [testUser1._id],
       },
       {
         path: '/normalize_7/normalize_8_gC',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdC,
+        grantedGroups: [{ item: groupIdC, type: 'UserGroup' }],
         grantedUsers: [testUser1._id],
       },
       {
@@ -231,13 +245,13 @@ describe('V5 page migration', () => {
         _id: pageId9, // not v5
         path: '/normalize_10/normalize_11_gA',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdA,
+        grantedGroups: [{ item: groupIdA, type: 'UserGroup' }],
       },
       {
         _id: pageId10,
         path: '/normalize_10/normalize_11_gA/normalize_11_gB',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdB,
+        grantedGroups: [{ item: groupIdB, type: 'UserGroup' }],
         parent: pageId8,
         descendantCount: 0,
       },
@@ -245,7 +259,7 @@ describe('V5 page migration', () => {
         _id: pageId11,
         path: '/normalize_10/normalize_12_gC',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdC,
+        grantedGroups: [{ item: groupIdC, type: 'UserGroup' }],
         grantedUsers: [testUser1._id],
         parent: pageId7,
         descendantCount: 0,
@@ -431,13 +445,13 @@ describe('V5 page migration', () => {
         {
           path: '/deep_path/normalize_a',
           grant: Page.GRANT_USER_GROUP,
-          grantedGroup: testUser1GroupId,
+          grantedGroups: [{ item: testUser1GroupId, type: 'UserGroup' }],
           parent: null,
         },
         {
           path: '/deep_path/normalize_c',
           grant: Page.GRANT_USER_GROUP,
-          grantedGroup: testUser1GroupId,
+          grantedGroups: [{ item: testUser1GroupId, type: 'UserGroup' }],
           parent: null,
         },
 
@@ -451,19 +465,19 @@ describe('V5 page migration', () => {
         {
           path: '/normalize_d',
           grant: Page.GRANT_USER_GROUP,
-          grantedGroup: testUser1GroupId,
+          grantedGroups: [{ item: testUser1GroupId, type: 'UserGroup' }],
           parent: null,
         },
         {
           path: '/normalize_d/normalize_e',
           grant: Page.GRANT_USER_GROUP,
-          grantedGroup: testUser1GroupId,
+          grantedGroups: [{ item: testUser1GroupId, type: 'UserGroup' }],
           parent: id2,
         },
         {
           path: '/normalize_f',
           grant: Page.GRANT_USER_GROUP,
-          grantedGroup: testUser1GroupId,
+          grantedGroups: [{ item: testUser1GroupId, type: 'UserGroup' }],
           parent: null,
         },
 
@@ -694,7 +708,7 @@ describe('V5 page migration', () => {
         {
           path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_group',
           grant: Page.GRANT_USER_GROUP,
-          grantedGroup: testUser1GroupId,
+          grantedGroups: [{ item: testUser1GroupId, type: 'UserGroup' }],
         },
 
         // 2
@@ -729,7 +743,7 @@ describe('V5 page migration', () => {
         {
           path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_group',
           grant: Page.GRANT_USER_GROUP,
-          grantedGroup: rootUserGroupId,
+          grantedGroups: [{ item: rootUserGroupId, type: 'UserGroup' }],
         },
 
         // 3
@@ -773,7 +787,7 @@ describe('V5 page migration', () => {
         {
           path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_rootGroup',
           grant: Page.GRANT_USER_GROUP,
-          grantedGroup: rootUserGroupId,
+          grantedGroups: [{ item: rootUserGroupId, type: 'UserGroup' }],
         },
       ]);
     });
@@ -1047,7 +1061,7 @@ describe('V5 page migration', () => {
       expect(page3AM.parent).toStrictEqual(rootPage._id);
     });
 
-    test('should throw error if a page with isolated group becomes the parent of other page with different gourp after normalizing', async() => {
+    test('should throw error if a page with isolated group becomes the parent of other page with different group after normalizing', async() => {
       const page4 = await Page.findOne({ _id: pageId4, path: '/normalize_4', isEmpty: true });
       const page5 = await Page.findOne({ _id: pageId5, path: '/normalize_4/normalize_5', parent: page4._id });
       const page6 = await Page.findOne({ _id: pageId6, path: '/normalize_4' }); // NOT v5

+ 169 - 71
apps/app/test/integration/service/v5.non-public-page.test.ts

@@ -1,8 +1,13 @@
 /* eslint-disable no-unused-vars */
-import { advanceTo } from 'jest-date-mock';
+import { GroupType, type GrantedGroup } from '@growi/core';
 import mongoose from 'mongoose';
 
+import { ExternalGroupProviderType } from '../../../src/features/external-user-group/interfaces/external-user-group';
+import ExternalUserGroup from '../../../src/features/external-user-group/server/models/external-user-group';
+import ExternalUserGroupRelation from '../../../src/features/external-user-group/server/models/external-user-group-relation';
 import Tag from '../../../src/server/models/tag';
+import UserGroup from '../../../src/server/models/user-group';
+import UserGroupRelation from '../../../src/server/models/user-group-relation';
 import { getInstance } from '../setup-crowi';
 
 describe('PageService page operations with non-public pages', () => {
@@ -16,17 +21,15 @@ describe('PageService page operations with non-public pages', () => {
   let groupIdA;
   let groupIdB;
   let groupIdC;
+  let externalGroupIdIsolate;
+  let externalGroupIdA;
+  let externalGroupIdB;
+  let externalGroupIdC;
   let crowi;
   let Page;
   let Revision;
   let User;
-  let UserGroup;
-  let UserGroupRelation;
   let PageTagRelation;
-  let Bookmark;
-  let Comment;
-  let ShareLink;
-  let PageRedirect;
   let xssSpy;
 
   let rootPage;
@@ -97,22 +100,22 @@ describe('PageService page operations with non-public pages', () => {
     return createdPage;
   };
 
+  // normalize for result comparison
+  const normalizeGrantedGroups = (grantedGroups: GrantedGroup[]) => {
+    return grantedGroups.map((group) => {
+      const itemId = typeof group.item === 'string' ? group.item : group.item._id;
+      return { item: itemId, type: group.type };
+    });
+  };
+
   beforeAll(async() => {
     crowi = await getInstance();
     await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
 
     User = mongoose.model('User');
-    UserGroup = mongoose.model('UserGroup');
-    UserGroupRelation = mongoose.model('UserGroupRelation');
     Page = mongoose.model('Page');
     Revision = mongoose.model('Revision');
     PageTagRelation = mongoose.model('PageTagRelation');
-    Bookmark = mongoose.model('Bookmark');
-    Comment = mongoose.model('Comment');
-    ShareLink = mongoose.model('ShareLink');
-    PageRedirect = mongoose.model('PageRedirect');
-    UserGroup = mongoose.model('UserGroup');
-    UserGroupRelation = mongoose.model('UserGroupRelation');
 
     /*
      * Common
@@ -201,6 +204,86 @@ describe('PageService page operations with non-public pages', () => {
       },
     ]);
 
+    // Insert ExternalUserGroups with the same group structure as UserGroups
+    // Use to test
+    //   - ExternalUserGroup
+    //   - Case of multiple grantedGroups for Page
+    externalGroupIdIsolate = new mongoose.Types.ObjectId();
+    externalGroupIdA = new mongoose.Types.ObjectId();
+    externalGroupIdB = new mongoose.Types.ObjectId();
+    externalGroupIdC = new mongoose.Types.ObjectId();
+    await ExternalUserGroup.insertMany([
+      {
+        _id: externalGroupIdIsolate,
+        name: 'np_externalGroupIsolate',
+        externalId: 'np_externalGroupIsolate',
+        provider: ExternalGroupProviderType.ldap,
+      },
+      {
+        _id: externalGroupIdA,
+        name: 'np_externalGroupA',
+        externalId: 'np_externalGroupA',
+        provider: ExternalGroupProviderType.ldap,
+      },
+      {
+        _id: externalGroupIdB,
+        name: 'np_externalGroupB',
+        externalId: 'np_externalGroupB',
+        parent: externalGroupIdA,
+        provider: ExternalGroupProviderType.ldap,
+      },
+      {
+        _id: externalGroupIdC,
+        name: 'np_externalGroupC',
+        externalId: 'np_externalGroupC',
+        parent: externalGroupIdB,
+        provider: ExternalGroupProviderType.ldap,
+      },
+    ]);
+
+    await ExternalUserGroupRelation.insertMany([
+      {
+        relatedGroup: externalGroupIdIsolate,
+        relatedUser: npUserId1,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: externalGroupIdIsolate,
+        relatedUser: npUserId2,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: externalGroupIdA,
+        relatedUser: npUserId1,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: externalGroupIdA,
+        relatedUser: npUserId2,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: externalGroupIdA,
+        relatedUser: npUserId3,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: externalGroupIdB,
+        relatedUser: npUserId2,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: externalGroupIdB,
+        relatedUser: npUserId3,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: externalGroupIdC,
+        relatedUser: npUserId3,
+        createdAt: new Date(),
+      },
+    ]);
+
     xssSpy = jest.spyOn(crowi.xss, 'process').mockImplementation(path => path);
 
     dummyUser1 = await User.findOne({ username: 'v5DummyUser1' });
@@ -343,7 +426,7 @@ describe('PageService page operations with non-public pages', () => {
         _id: pageIdRename2,
         path: '/np_rename2',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdB,
+        grantedGroups: [{ item: groupIdB, type: GroupType.userGroup }, { item: externalGroupIdB, type: GroupType.externalUserGroup }],
         creator: npDummyUser2._id,
         lastUpdateUser: npDummyUser2._id,
         parent: rootPage._id,
@@ -352,7 +435,7 @@ describe('PageService page operations with non-public pages', () => {
         _id: pageIdRename3,
         path: '/np_rename2/np_rename3',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdC,
+        grantedGroups: [{ item: groupIdC, type: GroupType.userGroup }, { item: externalGroupIdC, type: GroupType.externalUserGroup }],
         creator: npDummyUser3._id,
         lastUpdateUser: npDummyUser3._id,
         parent: pageIdRename2._id,
@@ -361,7 +444,7 @@ describe('PageService page operations with non-public pages', () => {
         _id: pageIdRename4,
         path: '/np_rename4_destination',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdIsolate,
+        grantedGroups: [{ item: groupIdIsolate, type: GroupType.userGroup }, { item: externalGroupIdIsolate, type: GroupType.externalUserGroup }],
         creator: npDummyUser3._id,
         lastUpdateUser: npDummyUser3._id,
         parent: rootPage._id,
@@ -370,7 +453,7 @@ describe('PageService page operations with non-public pages', () => {
         _id: pageIdRename5,
         path: '/np_rename5',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdB,
+        grantedGroups: [{ item: groupIdB, type: GroupType.userGroup }, { item: externalGroupIdB, type: GroupType.externalUserGroup }],
         creator: npDummyUser2._id,
         lastUpdateUser: npDummyUser2._id,
         parent: rootPage._id,
@@ -379,7 +462,7 @@ describe('PageService page operations with non-public pages', () => {
         _id: pageIdRename6,
         path: '/np_rename5/np_rename6',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdB,
+        grantedGroups: [{ item: groupIdB, type: GroupType.userGroup }, { item: externalGroupIdB, type: GroupType.externalUserGroup }],
         creator: npDummyUser2._id,
         lastUpdateUser: npDummyUser2._id,
         parent: pageIdRename5,
@@ -388,7 +471,7 @@ describe('PageService page operations with non-public pages', () => {
         _id: pageIdRename7,
         path: '/np_rename7_destination',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdIsolate,
+        grantedGroups: [{ item: groupIdIsolate, type: GroupType.userGroup }, { item: externalGroupIdIsolate, type: GroupType.externalUserGroup }],
         creator: npDummyUser2._id,
         lastUpdateUser: npDummyUser2._id,
         parent: pageIdRename5,
@@ -424,7 +507,7 @@ describe('PageService page operations with non-public pages', () => {
         _id: pageIdDuplicate2,
         path: '/np_duplicate2',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdA,
+        grantedGroups: [{ item: groupIdA, type: GroupType.userGroup }, { item: externalGroupIdA, type: GroupType.externalUserGroup }],
         creator: npDummyUser1._id,
         lastUpdateUser: npDummyUser1._id,
         revision: revisionIdDuplicate2,
@@ -434,7 +517,7 @@ describe('PageService page operations with non-public pages', () => {
         _id: pageIdDuplicate3,
         path: '/np_duplicate2/np_duplicate3',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdB,
+        grantedGroups: [{ item: groupIdB, type: GroupType.userGroup }, { item: externalGroupIdB, type: GroupType.externalUserGroup }],
         creator: npDummyUser2._id,
         lastUpdateUser: npDummyUser2._id,
         revision: revisionIdDuplicate3,
@@ -531,7 +614,7 @@ describe('PageService page operations with non-public pages', () => {
         _id: pageIdDelete2,
         path: '/npdel2_ug',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdA,
+        grantedGroups: [{ item: groupIdA, type: GroupType.userGroup }, { item: externalGroupIdA, type: GroupType.externalUserGroup }],
         status: Page.STATUS_PUBLISHED,
         isEmpty: false,
         parent: rootPage._id,
@@ -541,7 +624,7 @@ describe('PageService page operations with non-public pages', () => {
         _id: pageIdDelete3,
         path: '/npdel3_top',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdA,
+        grantedGroups: [{ item: groupIdA, type: GroupType.userGroup }, { item: externalGroupIdA, type: GroupType.externalUserGroup }],
         status: Page.STATUS_PUBLISHED,
         isEmpty: false,
         parent: rootPage._id,
@@ -551,7 +634,7 @@ describe('PageService page operations with non-public pages', () => {
         _id: pageIdDelete4,
         path: '/npdel3_top/npdel4_ug',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdB,
+        grantedGroups: [{ item: groupIdB, type: GroupType.userGroup }, { item: externalGroupIdB, type: GroupType.externalUserGroup }],
         status: Page.STATUS_PUBLISHED,
         isEmpty: false,
         parent: pageIdDelete3._id,
@@ -566,7 +649,7 @@ describe('PageService page operations with non-public pages', () => {
       {
         path: '/npdel3_top/npdel4_ug/npdel5_ug',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdC,
+        grantedGroups: [{ item: groupIdC, type: GroupType.userGroup }, { item: externalGroupIdC, type: GroupType.externalUserGroup }],
         status: Page.STATUS_PUBLISHED,
         isEmpty: false,
         parent: pageIdDelete4._id,
@@ -589,7 +672,7 @@ describe('PageService page operations with non-public pages', () => {
       {
         path: '/npdc2_ug',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdA,
+        grantedGroups: [{ item: groupIdA, type: GroupType.userGroup }, { item: externalGroupIdA, type: GroupType.externalUserGroup }],
         status: Page.STATUS_PUBLISHED,
         isEmpty: false,
         parent: rootPage._id,
@@ -598,7 +681,7 @@ describe('PageService page operations with non-public pages', () => {
         _id: pageIdDeleteComp1,
         path: '/npdc3_ug',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdA,
+        grantedGroups: [{ item: groupIdA, type: GroupType.userGroup }, { item: externalGroupIdA, type: GroupType.externalUserGroup }],
         status: Page.STATUS_PUBLISHED,
         isEmpty: false,
         parent: rootPage._id,
@@ -607,7 +690,7 @@ describe('PageService page operations with non-public pages', () => {
         _id: pageIdDeleteComp2,
         path: '/npdc3_ug/npdc4_ug',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdB,
+        grantedGroups: [{ item: groupIdB, type: GroupType.userGroup }, { item: externalGroupIdB, type: GroupType.externalUserGroup }],
         status: Page.STATUS_PUBLISHED,
         isEmpty: false,
         parent: pageIdDeleteComp1,
@@ -615,7 +698,7 @@ describe('PageService page operations with non-public pages', () => {
       {
         path: '/npdc3_ug/npdc4_ug/npdc5_ug',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdC,
+        grantedGroups: [{ item: groupIdC, type: GroupType.userGroup }, { item: externalGroupIdC, type: GroupType.externalUserGroup }],
         status: Page.STATUS_PUBLISHED,
         isEmpty: false,
         parent: pageIdDeleteComp2,
@@ -643,7 +726,7 @@ describe('PageService page operations with non-public pages', () => {
         _id: pageIdRevert2,
         path: '/trash/np_revert2',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdA,
+        grantedGroups: [{ item: groupIdA, type: GroupType.userGroup }, { item: externalGroupIdA, type: GroupType.externalUserGroup }],
         revision: revisionIdRevert2,
         status: Page.STATUS_DELETED,
       },
@@ -665,7 +748,7 @@ describe('PageService page operations with non-public pages', () => {
         _id: pageIdRevert5,
         path: '/trash/np_revert5',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdA,
+        grantedGroups: [{ item: groupIdA, type: GroupType.userGroup }, { item: externalGroupIdA, type: GroupType.externalUserGroup }],
         revision: revisionIdRevert5,
         status: Page.STATUS_DELETED,
       },
@@ -673,7 +756,7 @@ describe('PageService page operations with non-public pages', () => {
         _id: pageIdRevert6,
         path: '/trash/np_revert5/middle/np_revert6',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroup: groupIdB,
+        grantedGroups: [{ item: groupIdB, type: GroupType.userGroup }, { item: externalGroupIdB, type: GroupType.externalUserGroup }],
         revision: revisionIdRevert6,
         status: Page.STATUS_DELETED,
       },
@@ -897,8 +980,8 @@ describe('PageService page operations with non-public pages', () => {
       const _path2 = '/np_rename2';
       const _path3 = '/np_rename2/np_rename3';
       const _propertiesD = { grant: Page.GRANT_PUBLIC };
-      const _properties2 = { grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdB };
-      const _properties3 = { grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdC };
+      const _properties2 = { grant: Page.GRANT_USER_GROUP, grantedGroups: { $elemMatch: { item: groupIdB } } };
+      const _properties3 = { grant: Page.GRANT_USER_GROUP, grantedGroups: { $elemMatch: { item: groupIdC } } };
       const _pageD = await Page.findOne({ path: _pathD, ..._propertiesD });
       const _page2 = await Page.findOne({ path: _path2, ..._properties2 });
       const _page3 = await Page.findOne({ path: _path3, ..._properties3, parent: _page2._id });
@@ -926,17 +1009,17 @@ describe('PageService page operations with non-public pages', () => {
       expect(page3Renamed).toBeTruthy();
       expect(page2Renamed.parent).toStrictEqual(_pageD._id);
       expect(page3Renamed.parent).toStrictEqual(page2Renamed._id);
-      expect(page2Renamed.grantedGroup).toStrictEqual(_page2.grantedGroup);
-      expect(page3Renamed.grantedGroup).toStrictEqual(_page3.grantedGroup);
+      expect(normalizeGrantedGroups(page2Renamed.grantedGroups)).toStrictEqual(normalizeGrantedGroups(_page2.grantedGroups));
+      expect(normalizeGrantedGroups(page3Renamed.grantedGroups)).toStrictEqual(normalizeGrantedGroups(_page3.grantedGroups));
       expect(xssSpy).toHaveBeenCalled();
     });
     test('Should throw with NOT grant normalized pages', async() => {
       const _pathD = '/np_rename4_destination';
       const _path2 = '/np_rename5';
       const _path3 = '/np_rename5/np_rename6';
-      const _propertiesD = { grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdIsolate };
-      const _properties2 = { grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdB };
-      const _properties3 = { grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdB };
+      const _propertiesD = { grant: Page.GRANT_USER_GROUP, grantedGroups: { $elemMatch: { item: groupIdIsolate } } };
+      const _properties2 = { grant: Page.GRANT_USER_GROUP, grantedGroups: { $elemMatch: { item: groupIdB } } };
+      const _properties3 = { grant: Page.GRANT_USER_GROUP, grantedGroups: { $elemMatch: { item: groupIdB } } };
       const _pageD = await Page.findOne({ path: _pathD, ..._propertiesD });// isolate
       const _page2 = await Page.findOne({ path: _path2, ..._properties2 });// groupIdB
       const _page3 = await Page.findOne({ path: _path3, ..._properties3, parent: _page2 });// groupIdB
@@ -971,7 +1054,7 @@ describe('PageService page operations with non-public pages', () => {
       const _pathD = '/np_rename7_destination';
       const _path2 = '/np_rename8';
       const _path3 = '/np_rename8/np_rename9';
-      const _pageD = await Page.findOne({ path: _pathD, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdIsolate });
+      const _pageD = await Page.findOne({ path: _pathD, grant: Page.GRANT_USER_GROUP, grantedGroups: { $elemMatch: { item: groupIdIsolate } } });
       const _page2 = await Page.findOne({ path: _path2, grant: Page.GRANT_RESTRICTED });
       const _page3 = await Page.findOne({ path: _path3, grant: Page.GRANT_RESTRICTED });
       expect(_pageD).toBeTruthy();
@@ -1042,9 +1125,9 @@ describe('PageService page operations with non-public pages', () => {
     test('Should duplicate multiple pages with GRANT_USER_GROUP', async() => {
       const _path1 = '/np_duplicate2';
       const _path2 = '/np_duplicate2/np_duplicate3';
-      const _page1 = await Page.findOne({ path: _path1, parent: rootPage._id, grantedGroup: groupIdA })
+      const _page1 = await Page.findOne({ path: _path1, parent: rootPage._id, grantedGroups: { $elemMatch: { item: groupIdA } } })
         .populate({ path: 'revision', model: 'Revision', grantedPage: groupIdA._id });
-      const _page2 = await Page.findOne({ path: _path2, parent: _page1._id, grantedGroup: groupIdB })
+      const _page2 = await Page.findOne({ path: _path2, parent: _page1._id, grantedGroups: { $elemMatch: { item: groupIdB } } })
         .populate({ path: 'revision', model: 'Revision', grantedPage: groupIdB._id });
       const _revision1 = _page1.revision;
       const _revision2 = _page2.revision;
@@ -1065,8 +1148,14 @@ describe('PageService page operations with non-public pages', () => {
       expect(duplicatedPage2).toBeTruthy();
       expect(duplicatedRevision1).toBeTruthy();
       expect(duplicatedRevision2).toBeTruthy();
-      expect(duplicatedPage1.grantedGroup).toStrictEqual(groupIdA._id);
-      expect(duplicatedPage2.grantedGroup).toStrictEqual(groupIdB._id);
+      expect(normalizeGrantedGroups(duplicatedPage1.grantedGroups)).toStrictEqual([
+        { item: groupIdA, type: GroupType.userGroup },
+        { item: externalGroupIdA, type: GroupType.externalUserGroup },
+      ]);
+      expect(normalizeGrantedGroups(duplicatedPage2.grantedGroups)).toStrictEqual([
+        { item: groupIdB, type: GroupType.userGroup },
+        { item: externalGroupIdB, type: GroupType.externalUserGroup },
+      ]);
       expect(duplicatedPage1.parent).toStrictEqual(_page1.parent);
       expect(duplicatedPage2.parent).toStrictEqual(duplicatedPage1._id);
       expect(duplicatedRevision1.body).toBe(_revision1.body);
@@ -1156,7 +1245,7 @@ describe('PageService page operations with non-public pages', () => {
     describe('Delete single page with grant USER_GROUP', () => {
       test('should be able to delete', async() => {
         const _path = '/npdel2_ug';
-        const _page1 = await Page.findOne({ path: _path, grantedGroup: groupIdA });
+        const _page1 = await Page.findOne({ path: _path, grantedGroups: { $elemMatch: { item: groupIdA } } });
         expect(_page1).toBeTruthy();
 
         const isRecursively = false;
@@ -1165,8 +1254,8 @@ describe('PageService page operations with non-public pages', () => {
           endpoint: '/_api/v3/pages/rename',
         });
 
-        const pageN = await Page.findOne({ path: _path, grantedGroup: groupIdA });
-        const page1 = await Page.findOne({ path: `/trash${_path}`, grantedGroup: groupIdA });
+        const pageN = await Page.findOne({ path: _path, grantedGroups: { $elemMatch: { item: groupIdA } } });
+        const page1 = await Page.findOne({ path: `/trash${_path}`, grantedGroups: { $elemMatch: { item: groupIdA } } });
         expect(pageN).toBeNull();
         expect(page1).toBeTruthy();
         expect(page1.status).toBe(Page.STATUS_DELETED);
@@ -1179,9 +1268,9 @@ describe('PageService page operations with non-public pages', () => {
         const _pathT = '/npdel3_top';
         const _path1 = '/npdel3_top/npdel4_ug';
         const _path2 = '/npdel3_top/npdel4_ug/npdel5_ug';
-        const _pageT = await Page.findOne({ path: _pathT, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA }); // A
-        const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdB }); // B
-        const _page2 = await Page.findOne({ path: _path2, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdC }); // C
+        const _pageT = await Page.findOne({ path: _pathT, grant: Page.GRANT_USER_GROUP, grantedGroups: { $elemMatch: { item: groupIdA } } }); // A
+        const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroups: { $elemMatch: { item: groupIdB } } }); // B
+        const _page2 = await Page.findOne({ path: _path2, grant: Page.GRANT_USER_GROUP, grantedGroups: { $elemMatch: { item: groupIdC } } }); // C
         const _pageR = await Page.findOne({ path: _path1, grant: Page.GRANT_RESTRICTED }); // Restricted
         expect(_pageT).toBeTruthy();
         expect(_page1).toBeTruthy();
@@ -1194,12 +1283,12 @@ describe('PageService page operations with non-public pages', () => {
           endpoint: '/_api/v3/pages/rename',
         });
 
-        const pageTNotExist = await Page.findOne({ path: _pathT, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA }); // A should not exist
-        const page1NotExist = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdB }); // B should not exist
-        const page2NotExist = await Page.findOne({ path: _path2, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdC }); // C should not exist
-        const pageT = await Page.findOne({ path: `/trash${_pathT}`, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA }); // A
-        const page1 = await Page.findOne({ path: `/trash${_path1}`, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdB }); // B
-        const page2 = await Page.findOne({ path: `/trash${_path2}`, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdC }); // C
+        const pageTNotExist = await Page.findOne({ path: _pathT, grant: Page.GRANT_USER_GROUP, grantedGroups: { $elemMatch: { item: groupIdA } } }); // A should not exist
+        const page1NotExist = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroups: { $elemMatch: { item: groupIdB } } }); // B should not exist
+        const page2NotExist = await Page.findOne({ path: _path2, grant: Page.GRANT_USER_GROUP, grantedGroups: { $elemMatch: { item: groupIdC } } }); // C should not exist
+        const pageT = await Page.findOne({ path: `/trash${_pathT}`, grant: Page.GRANT_USER_GROUP, grantedGroups: { $elemMatch: { item: groupIdA } } }); // A
+        const page1 = await Page.findOne({ path: `/trash${_path1}`, grant: Page.GRANT_USER_GROUP, grantedGroups: { $elemMatch: { item: groupIdB } } }); // B
+        const page2 = await Page.findOne({ path: `/trash${_path2}`, grant: Page.GRANT_USER_GROUP, grantedGroups: { $elemMatch: { item: groupIdC } } }); // C
         const pageR = await Page.findOne({ path: _path1, grant: Page.GRANT_RESTRICTED }); // Restricted
         expect(page1NotExist).toBeNull();
         expect(pageTNotExist).toBeNull();
@@ -1256,7 +1345,7 @@ describe('PageService page operations with non-public pages', () => {
     describe('Delete single page with grant USER_GROUP', () => {
       test('should be able to delete completely', async() => {
         const _path = '/npdc2_ug';
-        const _page = await Page.findOne({ path: _path, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
+        const _page = await Page.findOne({ path: _path, grant: Page.GRANT_USER_GROUP, grantedGroups: { $elemMatch: { item: groupIdA } } });
         expect(_page).toBeTruthy();
 
         await deleteCompletely(_page, npDummyUser1, {}, false, false, {
@@ -1264,7 +1353,7 @@ describe('PageService page operations with non-public pages', () => {
           endpoint: '/_api/v3/pages/rename',
         });
 
-        const page = await Page.findOne({ path: _path, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
+        const page = await Page.findOne({ path: _path, grant: Page.GRANT_USER_GROUP, grantedGroups: { $elemMatch: { item: groupIdA } } });
         expect(page).toBeNull();
       });
     });
@@ -1273,9 +1362,9 @@ describe('PageService page operations with non-public pages', () => {
         const _path1 = '/npdc3_ug';
         const _path2 = '/npdc3_ug/npdc4_ug';
         const _path3 = '/npdc3_ug/npdc4_ug/npdc5_ug';
-        const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
-        const _page2 = await Page.findOne({ path: _path2, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdB });
-        const _page3 = await Page.findOne({ path: _path3, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdC });
+        const _page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroups: { $elemMatch: { item: groupIdA } } });
+        const _page2 = await Page.findOne({ path: _path2, grant: Page.GRANT_USER_GROUP, grantedGroups: { $elemMatch: { item: groupIdB } } });
+        const _page3 = await Page.findOne({ path: _path3, grant: Page.GRANT_USER_GROUP, grantedGroups: { $elemMatch: { item: groupIdC } } });
         const _page4 = await Page.findOne({ path: _path2, grant: Page.GRANT_RESTRICTED });
         expect(_page1).toBeTruthy();
         expect(_page2).toBeTruthy();
@@ -1287,9 +1376,9 @@ describe('PageService page operations with non-public pages', () => {
           endpoint: '/_api/v3/pages/rename',
         });
 
-        const page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
-        const page2 = await Page.findOne({ path: _path2, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdB });
-        const page3 = await Page.findOne({ path: _path3, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdC });
+        const page1 = await Page.findOne({ path: _path1, grant: Page.GRANT_USER_GROUP, grantedGroups: { $elemMatch: { item: groupIdA } } });
+        const page2 = await Page.findOne({ path: _path2, grant: Page.GRANT_USER_GROUP, grantedGroups: { $elemMatch: { item: groupIdB } } });
+        const page3 = await Page.findOne({ path: _path3, grant: Page.GRANT_USER_GROUP, grantedGroups: { $elemMatch: { item: groupIdC } } });
         const page4 = await Page.findOne({ path: _path2, grant: Page.GRANT_RESTRICTED });
 
         expect(page1).toBeNull();
@@ -1371,7 +1460,10 @@ describe('PageService page operations with non-public pages', () => {
       expect(revertedPage.parent).toStrictEqual(rootPage._id);
       expect(revertedPage.status).toBe(Page.STATUS_PUBLISHED);
       expect(revertedPage.grant).toBe(Page.GRANT_USER_GROUP);
-      expect(revertedPage.grantedGroup).toStrictEqual(groupIdA);
+      expect(normalizeGrantedGroups(revertedPage.grantedGroups)).toStrictEqual([
+        { item: groupIdA, type: GroupType.userGroup },
+        { item: externalGroupIdA, type: GroupType.externalUserGroup },
+      ]);
       expect(pageTagRelation.isPageTrashed).toBe(false);
     });
     test(`revert multiple pages: only target page should be reverted.
@@ -1417,8 +1509,8 @@ describe('PageService page operations with non-public pages', () => {
       const beforeRevertPath1 = '/trash/np_revert5';
       const beforeRevertPath2 = '/trash/np_revert5/middle/np_revert6';
       const beforeRevertPath3 = '/trash/np_revert5/middle';
-      const trashedPage1 = await Page.findOne({ path: beforeRevertPath1, status: Page.STATUS_DELETED, grantedGroup: groupIdA });
-      const trashedPage2 = await Page.findOne({ path: beforeRevertPath2, status: Page.STATUS_DELETED, grantedGroup: groupIdB });
+      const trashedPage1 = await Page.findOne({ path: beforeRevertPath1, status: Page.STATUS_DELETED, grantedGroups: { $elemMatch: { item: groupIdA } } });
+      const trashedPage2 = await Page.findOne({ path: beforeRevertPath2, status: Page.STATUS_DELETED, grantedGroups: { $elemMatch: { item: groupIdB } } });
       const nonExistantPage3 = await Page.findOne({ path: beforeRevertPath3 }); // not exist
       const revision1 = await Revision.findOne({ pageId: trashedPage1._id });
       const revision2 = await Revision.findOne({ pageId: trashedPage2._id });
@@ -1453,8 +1545,14 @@ describe('PageService page operations with non-public pages', () => {
       expect(revertedPage1.status).toBe(Page.STATUS_PUBLISHED);
       expect(revertedPage2.status).toBe(Page.STATUS_PUBLISHED);
       expect(newlyCreatedPage.status).toBe(Page.STATUS_PUBLISHED);
-      expect(revertedPage1.grantedGroup).toStrictEqual(groupIdA);
-      expect(revertedPage2.grantedGroup).toStrictEqual(groupIdB);
+      expect(normalizeGrantedGroups(revertedPage1.grantedGroups)).toStrictEqual([
+        { item: groupIdA, type: GroupType.userGroup },
+        { item: externalGroupIdA, type: GroupType.externalUserGroup },
+      ]);
+      expect(normalizeGrantedGroups(revertedPage2.grantedGroups)).toStrictEqual([
+        { item: groupIdB, type: GroupType.userGroup },
+        { item: externalGroupIdB, type: GroupType.externalUserGroup },
+      ]);
       expect(newlyCreatedPage.grant).toBe(Page.GRANT_PUBLIC);
 
     });

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

@@ -10,8 +10,6 @@ describe('Test page service methods', () => {
   let Page;
   let Revision;
   let User;
-  let UserGroup;
-  let UserGroupRelation;
   let Tag;
   let PageTagRelation;
   let Bookmark;
@@ -28,10 +26,6 @@ describe('Test page service methods', () => {
   let globalGroupUser1;
   let globalGroupUser2;
   let globalGroupUser3;
-  let globalGroupIsolate;
-  let globalGroupA;
-  let globalGroupB;
-  let globalGroupC;
 
   let pageOpId1;
   let pageOpId2;
@@ -45,8 +39,6 @@ describe('Test page service methods', () => {
     await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
 
     User = mongoose.model('User');
-    UserGroup = mongoose.model('UserGroup');
-    UserGroupRelation = mongoose.model('UserGroupRelation');
     Page = mongoose.model('Page');
     Revision = mongoose.model('Revision');
     Tag = mongoose.model('Tag');
@@ -55,8 +47,6 @@ describe('Test page service methods', () => {
     Comment = mongoose.model('Comment');
     ShareLink = mongoose.model('ShareLink');
     PageRedirect = mongoose.model('PageRedirect');
-    UserGroup = mongoose.model('UserGroup');
-    UserGroupRelation = mongoose.model('UserGroupRelation');
     PageOperation = mongoose.model('PageOperation');
 
     /*
@@ -73,11 +63,6 @@ describe('Test page service methods', () => {
     globalGroupUser1 = await User.findOne({ username: 'gGroupUser1' });
     globalGroupUser2 = await User.findOne({ username: 'gGroupUser2' });
     globalGroupUser3 = await User.findOne({ username: 'gGroupUser3' });
-    // groups
-    globalGroupIsolate = await UserGroup.findOne({ name: 'globalGroupIsolate' });
-    globalGroupA = await UserGroup.findOne({ name: 'globalGroupA' });
-    globalGroupB = await UserGroup.findOne({ name: 'globalGroupB' });
-    globalGroupC = await UserGroup.findOne({ name: 'globalGroupC' });
     // page
     rootPage = await Page.findOne({ path: '/' });
 
@@ -385,7 +370,7 @@ describe('Test page service methods', () => {
           status: 'published',
           grant: 1,
           grantedUsers: [],
-          grantedGroup: null,
+          grantedGroups: null,
           creator: dummyUser1._id,
           lastUpdateUser: dummyUser1._id,
         },
@@ -414,7 +399,7 @@ describe('Test page service methods', () => {
           status: 'published',
           grant: 1,
           grantedUsers: [],
-          grantedGroup: null,
+          grantedGroups: null,
           creator: dummyUser1._id,
           lastUpdateUser: dummyUser1._id,
         },
@@ -443,7 +428,7 @@ describe('Test page service methods', () => {
           status: 'published',
           grant: 1,
           grantedUsers: [],
-          grantedGroup: null,
+          grantedGroups: null,
           creator: dummyUser1._id,
           lastUpdateUser: dummyUser1._id,
         },
@@ -472,7 +457,7 @@ describe('Test page service methods', () => {
           status: 'published',
           grant: Page.GRANT_PUBLIC,
           grantedUsers: [],
-          grantedGroup: null,
+          grantedGroups: null,
           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: [],
-          grantedGroup: null,
+          grantedGroups: null,
           creator: dummyUser1._id,
           lastUpdateUser: dummyUser1._id,
         },

+ 13 - 4
packages/core/src/interfaces/page.ts

@@ -3,8 +3,17 @@ import type { HasObjectId } from './has-object-id';
 import type { IRevision, HasRevisionShortbody, IRevisionHasId } from './revision';
 import type { SubscriptionStatusType } from './subscription';
 import type { ITag } from './tag';
-import type { IUser, IUserGroupHasId, IUserHasId } from './user';
+import type {
+  IUser, IUserGroup, IUserGroupHasId, IUserHasId,
+} from './user';
 
+export const GroupType = { userGroup: 'UserGroup', externalUserGroup: 'ExternalUserGroup' } as const;
+export type GroupType = typeof GroupType[keyof typeof GroupType];
+
+export type GrantedGroup = {
+  type: GroupType,
+  item: Ref<IUserGroup>,
+}
 
 export type IPage = {
   path: string,
@@ -20,7 +29,7 @@ export type IPage = {
   isEmpty: boolean,
   grant: PageGrant,
   grantedUsers: Ref<IUser>[],
-  grantedGroup: Ref<any>,
+  grantedGroups: GrantedGroup[],
   lastUpdateUser: Ref<IUser>,
   liker: Ref<IUser>[],
   commentCount: number
@@ -39,11 +48,11 @@ export type IPagePopulatedToList = Omit<IPageHasId, 'lastUpdateUser'> & {
   lastUpdateUser: IUserHasId,
 }
 
-export type IPagePopulatedToShowRevision = Omit<IPageHasId, 'lastUpdateUser'|'creator'|'deleteUser'|'grantedGroup'|'revision'|'author'> & {
+export type IPagePopulatedToShowRevision = Omit<IPageHasId, 'lastUpdateUser'|'creator'|'deleteUser'|'grantedGroups'|'revision'|'author'> & {
   lastUpdateUser: IUserHasId,
   creator: IUserHasId | null,
   deleteUser: IUserHasId,
-  grantedGroup: IUserGroupHasId,
+  grantedGroups: { type: GroupType, item: IUserGroupHasId }[],
   revision: IRevisionHasId,
   author: IUserHasId,
 }