Przeglądaj źródła

Merge pull request #7967 from weseek/fix-api-to-use-granted-user-groups

Fix api to use granted user groups
Yuki Takei 2 lat temu
rodzic
commit
7da11672c0
40 zmienionych plików z 349 dodań i 210 usunięć
  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. 4 4
      apps/app/src/components/PageAlert/FixPageGrantAlert.tsx
  7. 4 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. 26 6
      apps/app/src/features/external-user-group/server/models/external-user-group-relation.integ.ts
  13. 4 2
      apps/app/src/features/external-user-group/server/models/external-user-group-relation.ts
  14. 7 1
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  15. 3 2
      apps/app/src/interfaces/page-operation.ts
  16. 5 4
      apps/app/src/interfaces/page.ts
  17. 10 7
      apps/app/src/pages/[[...path]].page.tsx
  18. 37 30
      apps/app/src/server/models/obsolete-page.js
  19. 26 19
      apps/app/src/server/models/page.ts
  20. 3 3
      apps/app/src/server/models/user-group-relation.ts
  21. 0 5
      apps/app/src/server/routes/admin.js
  22. 33 20
      apps/app/src/server/routes/apiv3/page.js
  23. 2 2
      apps/app/src/server/routes/apiv3/pages.js
  24. 1 2
      apps/app/src/server/routes/apiv3/user-group-relation.js
  25. 14 3
      apps/app/src/server/routes/apiv3/user-group.js
  26. 3 1
      apps/app/src/server/routes/apiv3/users.js
  27. 2 2
      apps/app/src/server/routes/me.js
  28. 5 5
      apps/app/src/server/routes/page.js
  29. 5 4
      apps/app/src/server/routes/search.ts
  30. 10 4
      apps/app/src/server/service/page-grant.ts
  31. 29 25
      apps/app/src/server/service/page.ts
  32. 8 6
      apps/app/src/server/service/search-delegator/elasticsearch.ts
  33. 3 2
      apps/app/src/server/service/search.ts
  34. 3 4
      apps/app/src/server/service/user-group.ts
  35. 35 12
      apps/app/src/server/util/compare-objectId.spec.ts
  36. 13 0
      apps/app/src/server/util/compare-objectId.ts
  37. 5 1
      apps/app/src/server/util/granted-group.ts
  38. 4 4
      apps/app/test/integration/service/v5.page.test.ts
  39. 1 1
      apps/app/test/integration/service/v5.public-page.test.ts
  40. 2 2
      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,
     };
 

+ 4 - 4
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,
@@ -58,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'));
@@ -87,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

+ 4 - 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,9 @@ 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.

+ 26 - 6
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() => {
@@ -78,13 +85,7 @@ describe('ExternalUserGroupRelation model', () => {
     const groupId2 = new mongoose.Types.ObjectId();
     const groupId3 = new mongoose.Types.ObjectId();
 
-    let user2;
-
     beforeAll(async() => {
-      user2 = await User.create({
-        name: 'user2', username: 'user2', email: 'user2@example.com',
-      });
-
       await ExternalUserGroupRelation.createRelations([groupId1, groupId2], user1);
       await ExternalUserGroupRelation.create({ relatedGroup: groupId3, relatedUser: user2._id });
     });
@@ -94,4 +95,23 @@ describe('ExternalUserGroupRelation model', () => {
       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]);
+    });
+  });
 });

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

@@ -21,7 +21,7 @@ export interface ExternalUserGroupRelationModel extends Model<ExternalUserGroupR
 
   findGroupsWithDescendantsByGroupAndUser: (group: ExternalUserGroupDocument, user) => Promise<ExternalUserGroupDocument[]>,
 
-  countByGroupIdAndUser: (userGroupId: string, userData) => Promise<number>
+  countByGroupIdsAndUser: (userGroupIds: ObjectIdLike[], userData) => Promise<number>
 }
 
 const schema = new Schema<ExternalUserGroupRelationDocument, ExternalUserGroupRelationModel>({
@@ -41,8 +41,10 @@ schema.statics.removeAllInvalidRelations = UserGroupRelation.removeAllInvalidRel
 
 schema.statics.findGroupsWithDescendantsByGroupAndUser = UserGroupRelation.findGroupsWithDescendantsByGroupAndUser;
 
-schema.statics.countByGroupIdAndUser = UserGroupRelation.countByGroupIdAndUser;
+schema.statics.countByGroupIdsAndUser = UserGroupRelation.countByGroupIdsAndUser;
 
 schema.statics.findAllUserIdsForUserGroups = UserGroupRelation.findAllUserIdsForUserGroups;
 
+schema.statics.findAllUserGroupIdsRelatedToUser = UserGroupRelation.findAllUserGroupIdsRelatedToUser;
+
 export default getOrCreateModel<ExternalUserGroupRelationDocument, ExternalUserGroupRelationModel>('ExternalUserGroupRelation', 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 - 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 = {

+ 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> {

+ 37 - 30
apps/app/src/server/models/obsolete-page.js

@@ -1,9 +1,12 @@
-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';
 
 
@@ -12,11 +15,10 @@ import UserGroupRelation from './user-group-relation';
 
 /* 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');
 
@@ -321,10 +323,10 @@ export const getPageSchema = (crowi) => {
   pageSchema.statics.isAccessiblePageByViewer = async function(id, user) {
     const baseQuery = this.count({ _id: id });
 
-    let userGroups = [];
-    if (user != null) {
-      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,10 +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) {
-      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);
@@ -382,10 +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) {
-      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);
@@ -509,10 +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) {
-      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);
   }
@@ -527,10 +529,10 @@ export const getPageSchema = (crowi) => {
    */
   async function addConditionToFilteringByViewerToEdit(builder, user) {
     // determine UserGroup condition
-    let userGroups = null;
-    if (user != null) {
-      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);
   }
@@ -655,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,
     });
 
@@ -674,7 +676,7 @@ export const getPageSchema = (crowi) => {
         updateOne: {
           filter: { _id: page._id },
           update: {
-            grantedGroup: null,
+            grantedGroups: null,
             grant: this.GRANT_PUBLIC,
           },
         },
@@ -683,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] });
   };
 
   /**

+ 26 - 19
apps/app/src/server/models/page.ts

@@ -18,6 +18,7 @@ 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';
@@ -100,17 +101,23 @@ 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' }],
-  grantedGroups: [{
-    type: {
-      type: String,
-      enum: Object.values(GroupType),
-      required: true,
-      default: 'UserGroup',
-    },
-    item: {
-      type: ObjectId, refPath: 'grantedGroups.type', required: true, 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,
+      },
+    }],
+    default: [],
+  },
   creator: { type: ObjectId, ref: 'User', index: true },
   lastUpdateUser: { type: ObjectId, ref: 'User' },
   liker: [{ type: ObjectId, ref: 'User' }],
@@ -319,10 +326,10 @@ export class PageQueryBuilder {
 
   async addConditionForParentNormalization(user): Promise<PageQueryBuilder> {
     // determine UserGroup condition
-    let userGroups;
-    if (user != null) {
-      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-    }
+    const userGroups = user != null ? [
+      ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+      ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+    ] : null;
 
     const grantConditions: any[] = [
       { grant: null },
@@ -370,10 +377,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) {
-      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;

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

@@ -25,7 +25,7 @@ export interface UserGroupRelationModel extends Model<UserGroupRelationDocument>
 
   findGroupsWithDescendantsByGroupAndUser: (group: UserGroupDocument, user) => Promise<UserGroupDocument[]>,
 
-  countByGroupIdAndUser: (userGroupId: string, userData) => Promise<number>
+  countByGroupIdsAndUser: (userGroupIds: ObjectIdLike[], userData) => Promise<number>
 }
 
 /*
@@ -156,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: string, userData): Promise<number> {
+schema.statics.countByGroupIdsAndUser = async function(userGroupIds: ObjectIdLike[], userData): Promise<number> {
   const query = {
-    relatedGroup: userGroupId,
+    relatedGroup: { $in: userGroupIds },
     relatedUser: userData.id,
   };
 

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

@@ -7,11 +7,6 @@ const debug = require('debug')('growi:routes:admin');
 
 /* eslint-disable no-use-before-define */
 module.exports = function(crowi, app) {
-
-  const models = crowi.models;
-  const UserGroupRelation = models.UserGroupRelation;
-  const GlobalNotificationSetting = models.GlobalNotificationSetting;
-
   const {
     configManager,
     aclService,

+ 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;
@@ -306,7 +306,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';
 
@@ -40,7 +43,6 @@ module.exports = (crowi) => {
   const activityEvent = crowi.event('activity');
 
   const {
-    UserGroupRelation,
     User,
     Page,
   } = crowi.models;
@@ -432,8 +434,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);
@@ -812,7 +819,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);
@@ -922,7 +922,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);

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

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

+ 10 - 4
apps/app/src/server/service/page-grant.ts

@@ -344,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: [
@@ -539,10 +542,10 @@ class PageGrantService {
       const applicableGroups = [...applicableUserGroups, ...applicableExternalUserGroups];
 
       const isUserExistInUserGroup = (await Promise.all(targetUserGroups.map((group) => {
-        return UserGroupRelation.countByGroupIdAndUser(group._id, user);
+        return UserGroupRelation.countByGroupIdsAndUser([group._id], user);
       }))).some(count => count > 0);
       const isUserExistInExternalUserGroup = (await Promise.all(targetExternalUserGroups.map((group) => {
-        return ExternalUserGroupRelation.countByGroupIdAndUser(group._id, user);
+        return ExternalUserGroupRelation.countByGroupIdsAndUser([group._id], user);
       }))).some(count => count > 0);
       const isUserExistInGroup = isUserExistInUserGroup || isUserExistInExternalUserGroup;
 
@@ -563,7 +566,10 @@ class PageGrantService {
    * @returns {Promise<boolean>}
    */
   async canOverwriteDescendants(targetPath: string, operator: { _id: ObjectIdLike }, updateGrantInfo: UpdateGrantInfo): Promise<boolean> {
-    const relatedGroupIds = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(operator);
+    const relatedGroupIds = [
+      ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(operator)),
+      ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(operator)),
+    ];
     const operatorGrantInfo = {
       userId: operator._id,
       userGroupIds: new Set<ObjectIdLike>(relatedGroupIds),

+ 29 - 25
apps/app/src/server/service/page.ts

@@ -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 {
@@ -39,6 +40,7 @@ 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');
 
@@ -2360,7 +2362,7 @@ 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({
       grantedGroups: {
@@ -2377,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');
@@ -2430,10 +2432,10 @@ class PageService {
     const MAX_LENGTH = 350;
 
     // aggregation options
-    let userGroups;
-    if (user != null && userGroups == null) {
-      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 },
@@ -2980,10 +2982,10 @@ class PageService {
     pathAndRegExpsToNormalize.push(...paths);
 
     // determine UserGroup condition
-    let userGroups = null;
-    if (user != null) {
-      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);
 
@@ -3382,10 +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) {
-      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 });
 
@@ -3531,13 +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 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.');
@@ -3736,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
@@ -3762,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 });
@@ -4071,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

@@ -381,16 +381,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,
     };
   }
 
@@ -891,7 +893,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;

+ 3 - 4
apps/app/src/server/service/user-group.ts

@@ -1,4 +1,4 @@
-import type { IUser } from '@growi/core';
+import type { IUser, GrantedGroup } from '@growi/core';
 import { Model } from 'mongoose';
 
 import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
@@ -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

+ 35 - 12
apps/app/src/server/util/compare-objectId.spec.ts

@@ -1,28 +1,51 @@
 import { Types } from 'mongoose';
 
-import { includesObjectIds } from './compare-objectId';
+import { hasIntersection, includesObjectIds } from './compare-objectId';
 
-describe('includesObjectIds', () => {
+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('When subset of array given', () => {
-    const arr = [id1, id2, id3, id4];
-    const subset = [id1, id4];
+  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);
+      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('When set that intersects with array given', () => {
-    const arr = [id1, id2, id3];
-    const subset = [id1, id4];
+  describe('hasIntersection', () => {
+    describe('When arrays have intersection', () => {
+      const arr1 = [id1, id2, id3, id4];
+      const arr2 = [id1, id4];
 
-    it('returns false', () => {
-      expect(includesObjectIds(arr, subset)).toBe(false);
+      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);
+      });
     });
   });
+
 });

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

@@ -18,6 +18,19 @@ export const includesObjectIds = (arr: ObjectIdLike[], potentialSubset: ObjectId
   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 _arr1.some(item => _arr2.includes(item));
+};
+
 /**
  * Exclude ObjectIds which exist in testIds from targetIds
  * @param targetIds Array of mongoose.Types.ObjectId

+ 5 - 1
apps/app/src/server/util/granted-group.ts

@@ -2,13 +2,17 @@ import { type GrantedGroup, GroupType } from '@growi/core';
 
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 
-export const divideByType = (grantedGroups: GrantedGroup[]): {
+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) {

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

@@ -385,7 +385,7 @@ describe('Test page service methods', () => {
           status: 'published',
           grant: 1,
           grantedUsers: [],
-          grantedGroup: null,
+          grantedGroups: null,
           creator: dummyUser1._id,
           lastUpdateUser: dummyUser1._id,
         },
@@ -414,7 +414,7 @@ describe('Test page service methods', () => {
           status: 'published',
           grant: 1,
           grantedUsers: [],
-          grantedGroup: null,
+          grantedGroups: null,
           creator: dummyUser1._id,
           lastUpdateUser: dummyUser1._id,
         },
@@ -443,7 +443,7 @@ describe('Test page service methods', () => {
           status: 'published',
           grant: 1,
           grantedUsers: [],
-          grantedGroup: null,
+          grantedGroups: null,
           creator: dummyUser1._id,
           lastUpdateUser: dummyUser1._id,
         },
@@ -472,7 +472,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

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

+ 2 - 2
packages/core/src/interfaces/page.ts

@@ -48,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,
 }