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

Merge pull request #9757 from weseek/feat/162852-163262-add-accesstokenparser-for-api-endpoint

feat: Implement access token parser with scope on almost all API endpoints
Yuki Takei 1 год назад
Родитель
Сommit
a96b51b99a
63 измененных файлов с 3352 добавлено и 2985 удалено
  1. 21 18
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group-relation.ts
  2. 255 234
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  3. 61 54
      apps/app/src/features/growi-plugin/server/routes/apiv3/admin/index.ts
  4. 2 1
      apps/app/src/features/openai/server/routes/ai-assistant.ts
  5. 2 1
      apps/app/src/features/openai/server/routes/ai-assistants.ts
  6. 2 1
      apps/app/src/features/openai/server/routes/delete-ai-assistant.ts
  7. 2 1
      apps/app/src/features/openai/server/routes/delete-thread.ts
  8. 2 1
      apps/app/src/features/openai/server/routes/get-messages.ts
  9. 2 1
      apps/app/src/features/openai/server/routes/get-threads.ts
  10. 2 1
      apps/app/src/features/openai/server/routes/message.ts
  11. 2 1
      apps/app/src/features/openai/server/routes/set-default-ai-assistant.ts
  12. 2 1
      apps/app/src/features/openai/server/routes/thread.ts
  13. 2 1
      apps/app/src/features/openai/server/routes/update-ai-assistant.ts
  14. 115 110
      apps/app/src/features/questionnaire/server/routes/apiv3/questionnaire.ts
  15. 34 29
      apps/app/src/features/templates/server/routes/apiv3/index.ts
  16. 27 16
      apps/app/src/interfaces/scope.ts
  17. 8 14
      apps/app/src/server/middlewares/access-token-parser/access-token.integ.ts
  18. 2 2
      apps/app/src/server/middlewares/access-token-parser/access-token.ts
  19. 0 4
      apps/app/src/server/middlewares/access-token-parser/api-token.integ.ts
  20. 2 2
      apps/app/src/server/middlewares/access-token-parser/api-token.ts
  21. 2 2
      apps/app/src/server/middlewares/access-token-parser/index.ts
  22. 2 1
      apps/app/src/server/routes/apiv3/activity.ts
  23. 3 1
      apps/app/src/server/routes/apiv3/admin-home.ts
  24. 110 102
      apps/app/src/server/routes/apiv3/app-settings.js
  25. 52 48
      apps/app/src/server/routes/apiv3/attachment.js
  26. 20 18
      apps/app/src/server/routes/apiv3/bookmark-folder.ts
  27. 47 45
      apps/app/src/server/routes/apiv3/bookmarks.js
  28. 262 240
      apps/app/src/server/routes/apiv3/customize-setting.js
  29. 21 18
      apps/app/src/server/routes/apiv3/export.js
  30. 3 2
      apps/app/src/server/routes/apiv3/g2g-transfer.ts
  31. 30 28
      apps/app/src/server/routes/apiv3/import.js
  32. 80 75
      apps/app/src/server/routes/apiv3/in-app-notification.ts
  33. 211 172
      apps/app/src/server/routes/apiv3/notification-setting.js
  34. 5 4
      apps/app/src/server/routes/apiv3/page-listing.ts
  35. 2 1
      apps/app/src/server/routes/apiv3/page/check-page-existence.ts
  36. 2 1
      apps/app/src/server/routes/apiv3/page/create-page.ts
  37. 2 1
      apps/app/src/server/routes/apiv3/page/get-page-paths-with-descendant-count.ts
  38. 2 1
      apps/app/src/server/routes/apiv3/page/get-yjs-data.ts
  39. 161 152
      apps/app/src/server/routes/apiv3/page/index.ts
  40. 2 1
      apps/app/src/server/routes/apiv3/page/publish-page.ts
  41. 2 1
      apps/app/src/server/routes/apiv3/page/sync-latest-revision-body-to-yjs-draft.ts
  42. 2 1
      apps/app/src/server/routes/apiv3/page/unpublish-page.ts
  43. 2 1
      apps/app/src/server/routes/apiv3/page/update-page.ts
  44. 308 237
      apps/app/src/server/routes/apiv3/pages/index.js
  45. 1 1
      apps/app/src/server/routes/apiv3/personal-setting/delete-access-token.ts
  46. 1 1
      apps/app/src/server/routes/apiv3/personal-setting/delete-all-access-tokens.ts
  47. 1 1
      apps/app/src/server/routes/apiv3/personal-setting/get-access-tokens.ts
  48. 141 131
      apps/app/src/server/routes/apiv3/personal-setting/index.js
  49. 85 82
      apps/app/src/server/routes/apiv3/revisions.js
  50. 31 29
      apps/app/src/server/routes/apiv3/search.js
  51. 334 315
      apps/app/src/server/routes/apiv3/security-settings/index.js
  52. 114 89
      apps/app/src/server/routes/apiv3/share-links.js
  53. 29 26
      apps/app/src/server/routes/apiv3/slack-integration-legacy-settings.js
  54. 122 114
      apps/app/src/server/routes/apiv3/slack-integration-settings.js
  55. 3 1
      apps/app/src/server/routes/apiv3/user-group-relation.js
  56. 328 287
      apps/app/src/server/routes/apiv3/user-group.js
  57. 2 1
      apps/app/src/server/routes/apiv3/user/get-related-groups.ts
  58. 218 203
      apps/app/src/server/routes/apiv3/users.js
  59. 3 1
      apps/app/src/server/routes/attachment/get-brand-logo.ts
  60. 2 1
      apps/app/src/server/routes/attachment/get.ts
  61. 35 32
      apps/app/src/server/routes/index.js
  62. 22 22
      apps/app/src/server/util/scope-util.spec.ts
  63. 2 2
      apps/app/src/server/util/scope-utils.ts

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

@@ -3,7 +3,9 @@ import type { Router, Request } from 'express';
 
 import type { IExternalUserGroupRelationHasId } from '~/features/external-user-group/interfaces/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
+import { SCOPE } from '~/interfaces/scope';
 import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { serializeUserGroupRelationSecurely } from '~/server/models/serializers/user-group-relation-serializer';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
@@ -28,28 +30,29 @@ module.exports = (crowi: Crowi): Router => {
     ],
   };
 
-  router.get('/', loginRequiredStrictly, adminRequired, validators.list, async(req: Request, res: ApiV3Response) => {
-    const { query } = req;
+  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired, validators.list,
+    async(req: Request, res: ApiV3Response) => {
+      const { query } = req;
 
-    try {
-      const relations = await ExternalUserGroupRelation.find({ relatedGroup: { $in: query.groupIds } }).populate('relatedUser');
+      try {
+        const relations = await ExternalUserGroupRelation.find({ relatedGroup: { $in: query.groupIds } }).populate('relatedUser');
 
-      let relationsOfChildGroups: IExternalUserGroupRelationHasId[] | null = null;
-      if (Array.isArray(query.childGroupIds)) {
-        const _relationsOfChildGroups = await ExternalUserGroupRelation.find({ relatedGroup: { $in: query.childGroupIds } }).populate('relatedUser');
-        relationsOfChildGroups = _relationsOfChildGroups.map(relation => serializeUserGroupRelationSecurely(relation)); // serialize
-      }
+        let relationsOfChildGroups: IExternalUserGroupRelationHasId[] | null = null;
+        if (Array.isArray(query.childGroupIds)) {
+          const _relationsOfChildGroups = await ExternalUserGroupRelation.find({ relatedGroup: { $in: query.childGroupIds } }).populate('relatedUser');
+          relationsOfChildGroups = _relationsOfChildGroups.map(relation => serializeUserGroupRelationSecurely(relation)); // serialize
+        }
 
-      const serialized = relations.map(relation => serializeUserGroupRelationSecurely(relation));
+        const serialized = relations.map(relation => serializeUserGroupRelationSecurely(relation));
 
-      return res.apiv3({ userGroupRelations: serialized, relationsOfChildGroups });
-    }
-    catch (err) {
-      const msg = 'Error occurred in fetching user group relations';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg));
-    }
-  });
+        return res.apiv3({ userGroupRelations: serialized, relationsOfChildGroups });
+      }
+      catch (err) {
+        const msg = 'Error occurred in fetching user group relations';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg));
+      }
+    });
 
   return router;
 };

+ 255 - 234
apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts

@@ -9,8 +9,10 @@ import {
 import ExternalUserGroup from '~/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import { SupportedAction } from '~/interfaces/activity';
+import { SCOPE } from '~/interfaces/scope';
 import type { PageActionOnGroupDelete } from '~/interfaces/user-group';
 import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { serializeUserGroupRelationSecurely } from '~/server/models/serializers/user-group-relation-serializer';
@@ -142,27 +144,28 @@ module.exports = (crowi: Crowi): Router => {
    *                     pagingLimit:
    *                       type: integer
    */
-  router.get('/', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    const { query } = req;
-
-    try {
-      const page = query.page != null ? parseInt(query.page as string) : undefined;
-      const limit = query.limit != null ? parseInt(query.limit as string) : undefined;
-      const offset = query.offset != null ? parseInt(query.offset as string) : undefined;
-      const pagination = query.pagination != null ? query.pagination !== 'false' : undefined;
-
-      const result = await ExternalUserGroup.findWithPagination({
-        page, limit, offset, pagination,
-      });
-      const { docs: userGroups, totalDocs: totalUserGroups, limit: pagingLimit } = result;
-      return res.apiv3({ userGroups, totalUserGroups, pagingLimit });
-    }
-    catch (err) {
-      const msg = 'Error occurred in fetching external user group list';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg));
-    }
-  });
+  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+    async(req: AuthorizedRequest, res: ApiV3Response) => {
+      const { query } = req;
+
+      try {
+        const page = query.page != null ? parseInt(query.page as string) : undefined;
+        const limit = query.limit != null ? parseInt(query.limit as string) : undefined;
+        const offset = query.offset != null ? parseInt(query.offset as string) : undefined;
+        const pagination = query.pagination != null ? query.pagination !== 'false' : undefined;
+
+        const result = await ExternalUserGroup.findWithPagination({
+          page, limit, offset, pagination,
+        });
+        const { docs: userGroups, totalDocs: totalUserGroups, limit: pagingLimit } = result;
+        return res.apiv3({ userGroups, totalUserGroups, pagingLimit });
+      }
+      catch (err) {
+        const msg = 'Error occurred in fetching external user group list';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg));
+      }
+    });
 
   /**
    * @swagger
@@ -194,20 +197,22 @@ module.exports = (crowi: Crowi): Router => {
    *                       items:
    *                         type: object
    */
-  router.get('/ancestors', loginRequiredStrictly, adminRequired, validators.ancestorGroup, apiV3FormValidator, async(req, res: ApiV3Response) => {
-    const { groupId } = req.query;
-
-    try {
-      const userGroup = await ExternalUserGroup.findOne({ _id: { $eq: groupId } });
-      const ancestorUserGroups = await ExternalUserGroup.findGroupsWithAncestorsRecursively(userGroup);
-      return res.apiv3({ ancestorUserGroups });
-    }
-    catch (err) {
-      const msg = 'Error occurred while searching user groups';
-      logger.error(msg, err);
-      return res.apiv3Err(new ErrorV3(msg));
-    }
-  });
+  router.get('/ancestors', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+    validators.ancestorGroup, apiV3FormValidator,
+    async(req, res: ApiV3Response) => {
+      const { groupId } = req.query;
+
+      try {
+        const userGroup = await ExternalUserGroup.findOne({ _id: { $eq: groupId } });
+        const ancestorUserGroups = await ExternalUserGroup.findGroupsWithAncestorsRecursively(userGroup);
+        return res.apiv3({ ancestorUserGroups });
+      }
+      catch (err) {
+        const msg = 'Error occurred while searching user groups';
+        logger.error(msg, err);
+        return res.apiv3Err(new ErrorV3(msg));
+      }
+    });
 
   /**
    * @swagger
@@ -249,22 +254,23 @@ module.exports = (crowi: Crowi): Router => {
    *                       items:
    *                         type: object
    */
-  router.get('/children', loginRequiredStrictly, adminRequired, validators.listChildren, async(req, res) => {
-    try {
-      const { parentIds, includeGrandChildren = false } = req.query;
-
-      const externalUserGroupsResult = await ExternalUserGroup.findChildrenByParentIds(parentIds, includeGrandChildren);
-      return res.apiv3({
-        childUserGroups: externalUserGroupsResult.childUserGroups,
-        grandChildUserGroups: externalUserGroupsResult.grandChildUserGroups,
-      });
-    }
-    catch (err) {
-      const msg = 'Error occurred in fetching child user group list';
-      logger.error(msg, err);
-      return res.apiv3Err(new ErrorV3(msg));
-    }
-  });
+  router.get('/children', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired, validators.listChildren,
+    async(req, res) => {
+      try {
+        const { parentIds, includeGrandChildren = false } = req.query;
+
+        const externalUserGroupsResult = await ExternalUserGroup.findChildrenByParentIds(parentIds, includeGrandChildren);
+        return res.apiv3({
+          childUserGroups: externalUserGroupsResult.childUserGroups,
+          grandChildUserGroups: externalUserGroupsResult.grandChildUserGroups,
+        });
+      }
+      catch (err) {
+        const msg = 'Error occurred in fetching child user group list';
+        logger.error(msg, err);
+        return res.apiv3Err(new ErrorV3(msg));
+      }
+    });
 
   /**
    * @swagger
@@ -294,19 +300,20 @@ module.exports = (crowi: Crowi): Router => {
    *                     userGroup:
    *                       type: object
    */
-  router.get('/:id', loginRequiredStrictly, adminRequired, validators.detail, async(req, res: ApiV3Response) => {
-    const { id } = req.params;
-
-    try {
-      const userGroup = await ExternalUserGroup.findById(id);
-      return res.apiv3({ userGroup });
-    }
-    catch (err) {
-      const msg = 'Error occurred while getting external user group';
-      logger.error(msg, err);
-      return res.apiv3Err(new ErrorV3(msg));
-    }
-  });
+  router.get('/:id', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired, validators.detail,
+    async(req, res: ApiV3Response) => {
+      const { id } = req.params;
+
+      try {
+        const userGroup = await ExternalUserGroup.findById(id);
+        return res.apiv3({ userGroup });
+      }
+      catch (err) {
+        const msg = 'Error occurred while getting external user group';
+        logger.error(msg, err);
+        return res.apiv3Err(new ErrorV3(msg));
+      }
+    });
 
   /**
    * @swagger
@@ -354,7 +361,8 @@ module.exports = (crowi: Crowi): Router => {
    *                       items:
    *                         type: object
    */
-  router.delete('/:id', loginRequiredStrictly, adminRequired, validators.delete, apiV3FormValidator, addActivity,
+  router.delete('/:id', accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+    validators.delete, apiV3FormValidator, addActivity,
     async(req: AuthorizedRequest, res: ApiV3Response) => {
       const { id: deleteGroupId } = req.params;
       const { transferToUserGroupId, transferToUserGroupType } = req.query;
@@ -420,26 +428,28 @@ module.exports = (crowi: Crowi): Router => {
    *                     userGroup:
    *                       type: object
    */
-  router.put('/:id', loginRequiredStrictly, adminRequired, validators.update, apiV3FormValidator, addActivity, async(req, res: ApiV3Response) => {
-    const { id } = req.params;
-    const {
-      description,
-    } = req.body;
-
-    try {
-      const userGroup = await ExternalUserGroup.findOneAndUpdate({ _id: id }, { $set: { description } });
-
-      const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-
-      return res.apiv3({ userGroup });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating an external user group';
-      logger.error(msg, err);
-      return res.apiv3Err(new ErrorV3(msg));
-    }
-  });
+  router.put('/:id', accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+    validators.update, apiV3FormValidator, addActivity,
+    async(req, res: ApiV3Response) => {
+      const { id } = req.params;
+      const {
+        description,
+      } = req.body;
+
+      try {
+        const userGroup = await ExternalUserGroup.findOneAndUpdate({ _id: id }, { $set: { description } });
+
+        const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3({ userGroup });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating an external user group';
+        logger.error(msg, err);
+        return res.apiv3Err(new ErrorV3(msg));
+      }
+    });
 
   /**
    * @swagger
@@ -471,22 +481,24 @@ module.exports = (crowi: Crowi): Router => {
    *                       items:
    *                         type: object
    */
-  router.get('/:id/external-user-group-relations', loginRequiredStrictly, adminRequired, async(req, res: ApiV3Response) => {
-    const { id } = req.params;
-
-    try {
-      const externalUserGroup = await ExternalUserGroup.findById(id);
-      const userGroupRelations = await ExternalUserGroupRelation.find({ relatedGroup: externalUserGroup })
-        .populate('relatedUser');
-      const serialized = userGroupRelations.map(relation => serializeUserGroupRelationSecurely(relation));
-      return res.apiv3({ userGroupRelations: serialized });
-    }
-    catch (err) {
-      const msg = `Error occurred in fetching user group relations for external user group: ${id}`;
-      logger.error(msg, err);
-      return res.apiv3Err(new ErrorV3(msg));
-    }
-  });
+  // TODO: add accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]) before loginRequiredStrictly
+  router.get('/:id/external-user-group-relations', loginRequiredStrictly, adminRequired,
+    async(req, res: ApiV3Response) => {
+      const { id } = req.params;
+
+      try {
+        const externalUserGroup = await ExternalUserGroup.findById(id);
+        const userGroupRelations = await ExternalUserGroupRelation.find({ relatedGroup: externalUserGroup })
+          .populate('relatedUser');
+        const serialized = userGroupRelations.map(relation => serializeUserGroupRelationSecurely(relation));
+        return res.apiv3({ userGroupRelations: serialized });
+      }
+      catch (err) {
+        const msg = `Error occurred in fetching user group relations for external user group: ${id}`;
+        logger.error(msg, err);
+        return res.apiv3Err(new ErrorV3(msg));
+      }
+    });
 
   /**
    * @swagger
@@ -523,20 +535,21 @@ module.exports = (crowi: Crowi): Router => {
    *                     ldapGroupDescriptionAttribute:
    *                       type: string
    */
-  router.get('/ldap/sync-settings', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
-    const settings = {
-      ldapGroupSearchBase: configManager.getConfig('external-user-group:ldap:groupSearchBase'),
-      ldapGroupMembershipAttribute: configManager.getConfig('external-user-group:ldap:groupMembershipAttribute'),
-      ldapGroupMembershipAttributeType: configManager.getConfig('external-user-group:ldap:groupMembershipAttributeType'),
-      ldapGroupChildGroupAttribute: configManager.getConfig('external-user-group:ldap:groupChildGroupAttribute'),
-      autoGenerateUserOnLdapGroupSync: configManager.getConfig('external-user-group:ldap:autoGenerateUserOnGroupSync'),
-      preserveDeletedLdapGroups: configManager.getConfig('external-user-group:ldap:preserveDeletedGroups'),
-      ldapGroupNameAttribute: configManager.getConfig('external-user-group:ldap:groupNameAttribute'),
-      ldapGroupDescriptionAttribute: configManager.getConfig('external-user-group:ldap:groupDescriptionAttribute'),
-    };
-
-    return res.apiv3(settings);
-  });
+  router.get('/ldap/sync-settings', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+    (req: AuthorizedRequest, res: ApiV3Response) => {
+      const settings = {
+        ldapGroupSearchBase: configManager.getConfig('external-user-group:ldap:groupSearchBase'),
+        ldapGroupMembershipAttribute: configManager.getConfig('external-user-group:ldap:groupMembershipAttribute'),
+        ldapGroupMembershipAttributeType: configManager.getConfig('external-user-group:ldap:groupMembershipAttributeType'),
+        ldapGroupChildGroupAttribute: configManager.getConfig('external-user-group:ldap:groupChildGroupAttribute'),
+        autoGenerateUserOnLdapGroupSync: configManager.getConfig('external-user-group:ldap:autoGenerateUserOnGroupSync'),
+        preserveDeletedLdapGroups: configManager.getConfig('external-user-group:ldap:preserveDeletedGroups'),
+        ldapGroupNameAttribute: configManager.getConfig('external-user-group:ldap:groupNameAttribute'),
+        ldapGroupDescriptionAttribute: configManager.getConfig('external-user-group:ldap:groupDescriptionAttribute'),
+      };
+
+      return res.apiv3(settings);
+    });
 
   /**
    * @swagger
@@ -573,20 +586,21 @@ module.exports = (crowi: Crowi): Router => {
    *                     keycloakGroupDescriptionAttribute:
    *                       type: string
    */
-  router.get('/keycloak/sync-settings', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
-    const settings = {
-      keycloakHost: configManager.getConfig('external-user-group:keycloak:host'),
-      keycloakGroupRealm: configManager.getConfig('external-user-group:keycloak:groupRealm'),
-      keycloakGroupSyncClientRealm: configManager.getConfig('external-user-group:keycloak:groupSyncClientRealm'),
-      keycloakGroupSyncClientID: configManager.getConfig('external-user-group:keycloak:groupSyncClientID'),
-      keycloakGroupSyncClientSecret: configManager.getConfig('external-user-group:keycloak:groupSyncClientSecret'),
-      autoGenerateUserOnKeycloakGroupSync: configManager.getConfig('external-user-group:keycloak:autoGenerateUserOnGroupSync'),
-      preserveDeletedKeycloakGroups: configManager.getConfig('external-user-group:keycloak:preserveDeletedGroups'),
-      keycloakGroupDescriptionAttribute: configManager.getConfig('external-user-group:keycloak:groupDescriptionAttribute'),
-    };
-
-    return res.apiv3(settings);
-  });
+  router.get('/keycloak/sync-settings', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+    (req: AuthorizedRequest, res: ApiV3Response) => {
+      const settings = {
+        keycloakHost: configManager.getConfig('external-user-group:keycloak:host'),
+        keycloakGroupRealm: configManager.getConfig('external-user-group:keycloak:groupRealm'),
+        keycloakGroupSyncClientRealm: configManager.getConfig('external-user-group:keycloak:groupSyncClientRealm'),
+        keycloakGroupSyncClientID: configManager.getConfig('external-user-group:keycloak:groupSyncClientID'),
+        keycloakGroupSyncClientSecret: configManager.getConfig('external-user-group:keycloak:groupSyncClientSecret'),
+        autoGenerateUserOnKeycloakGroupSync: configManager.getConfig('external-user-group:keycloak:autoGenerateUserOnGroupSync'),
+        preserveDeletedKeycloakGroups: configManager.getConfig('external-user-group:keycloak:preserveDeletedGroups'),
+        keycloakGroupDescriptionAttribute: configManager.getConfig('external-user-group:keycloak:groupDescriptionAttribute'),
+      };
+
+      return res.apiv3(settings);
+    });
 
   /**
    * @swagger
@@ -629,41 +643,43 @@ module.exports = (crowi: Crowi): Router => {
    *                 schema:
    *                   type: object
    */
-  router.put('/ldap/sync-settings', loginRequiredStrictly, adminRequired, validators.ldapSyncSettings, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    const errors = validationResult(req);
-    if (!errors.isEmpty()) {
-      return res.apiv3Err(
-        new ErrorV3('Invalid sync settings', 'external_user_group.invalid_sync_settings'), 400,
-      );
-    }
-
-    const params = {
-      'external-user-group:ldap:groupSearchBase': req.body.ldapGroupSearchBase,
-      'external-user-group:ldap:groupMembershipAttribute': req.body.ldapGroupMembershipAttribute,
-      'external-user-group:ldap:groupMembershipAttributeType': req.body.ldapGroupMembershipAttributeType,
-      'external-user-group:ldap:groupChildGroupAttribute': req.body.ldapGroupChildGroupAttribute,
-      'external-user-group:ldap:autoGenerateUserOnGroupSync': req.body.autoGenerateUserOnLdapGroupSync,
-      'external-user-group:ldap:preserveDeletedGroups': req.body.preserveDeletedLdapGroups,
-      'external-user-group:ldap:groupNameAttribute': req.body.ldapGroupNameAttribute,
-      'external-user-group:ldap:groupDescriptionAttribute': req.body.ldapGroupDescriptionAttribute,
-    };
-
-    if (params['external-user-group:ldap:groupNameAttribute'] == null || params['external-user-group:ldap:groupNameAttribute'] === '') {
+  router.put('/ldap/sync-settings', accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+    validators.ldapSyncSettings,
+    async(req: AuthorizedRequest, res: ApiV3Response) => {
+      const errors = validationResult(req);
+      if (!errors.isEmpty()) {
+        return res.apiv3Err(
+          new ErrorV3('Invalid sync settings', 'external_user_group.invalid_sync_settings'), 400,
+        );
+      }
+
+      const params = {
+        'external-user-group:ldap:groupSearchBase': req.body.ldapGroupSearchBase,
+        'external-user-group:ldap:groupMembershipAttribute': req.body.ldapGroupMembershipAttribute,
+        'external-user-group:ldap:groupMembershipAttributeType': req.body.ldapGroupMembershipAttributeType,
+        'external-user-group:ldap:groupChildGroupAttribute': req.body.ldapGroupChildGroupAttribute,
+        'external-user-group:ldap:autoGenerateUserOnGroupSync': req.body.autoGenerateUserOnLdapGroupSync,
+        'external-user-group:ldap:preserveDeletedGroups': req.body.preserveDeletedLdapGroups,
+        'external-user-group:ldap:groupNameAttribute': req.body.ldapGroupNameAttribute,
+        'external-user-group:ldap:groupDescriptionAttribute': req.body.ldapGroupDescriptionAttribute,
+      };
+
+      if (params['external-user-group:ldap:groupNameAttribute'] == null || params['external-user-group:ldap:groupNameAttribute'] === '') {
       // default is cn
-      params['external-user-group:ldap:groupNameAttribute'] = 'cn';
-    }
-
-    try {
-      await configManager.updateConfigs(params, { skipPubsub: true });
-      return res.apiv3({}, 204);
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(
-        new ErrorV3('Sync settings update failed', 'external_user_group.update_sync_settings_failed'), 500,
-      );
-    }
-  });
+        params['external-user-group:ldap:groupNameAttribute'] = 'cn';
+      }
+
+      try {
+        await configManager.updateConfigs(params, { skipPubsub: true });
+        return res.apiv3({}, 204);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(
+          new ErrorV3('Sync settings update failed', 'external_user_group.update_sync_settings_failed'), 500,
+        );
+      }
+    });
 
   /**
    * @swagger
@@ -706,7 +722,8 @@ module.exports = (crowi: Crowi): Router => {
    *                 schema:
    *                   type: object
    */
-  router.put('/keycloak/sync-settings', loginRequiredStrictly, adminRequired, validators.keycloakSyncSettings,
+  router.put('/keycloak/sync-settings', accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+    validators.keycloakSyncSettings,
     async(req: AuthorizedRequest, res: ApiV3Response) => {
       const errors = validationResult(req);
       if (!errors.isEmpty()) {
@@ -756,34 +773,35 @@ module.exports = (crowi: Crowi): Router => {
    *                 schema:
    *                   type: object
    */
-  router.put('/ldap/sync', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    if (isExecutingSync()) {
-      return res.apiv3Err(
-        new ErrorV3('There is an ongoing sync process', 'external_user_group.sync_being_executed'), 409,
-      );
-    }
-
-    const isLdapEnabled = await configManager.getConfig('security:passport-ldap:isEnabled');
-    if (!isLdapEnabled) {
-      return res.apiv3Err(
-        new ErrorV3('Authentication using ldap is not set', 'external_user_group.ldap.auth_not_set'), 422,
-      );
-    }
-
-    try {
-      await crowi.ldapUserGroupSyncService?.init(req.user.name, req.body.password);
-    }
-    catch (e) {
-      return res.apiv3Err(
-        new ErrorV3('LDAP group sync failed', 'external_user_group.sync_failed'), 500,
-      );
-    }
-
-    // Do not await for sync to finish. Result (completed, failed) will be notified to the client by socket-io.
-    crowi.ldapUserGroupSyncService?.syncExternalUserGroups();
-
-    return res.apiv3({}, 202);
-  });
+  router.put('/ldap/sync', accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+    async(req: AuthorizedRequest, res: ApiV3Response) => {
+      if (isExecutingSync()) {
+        return res.apiv3Err(
+          new ErrorV3('There is an ongoing sync process', 'external_user_group.sync_being_executed'), 409,
+        );
+      }
+
+      const isLdapEnabled = await configManager.getConfig('security:passport-ldap:isEnabled');
+      if (!isLdapEnabled) {
+        return res.apiv3Err(
+          new ErrorV3('Authentication using ldap is not set', 'external_user_group.ldap.auth_not_set'), 422,
+        );
+      }
+
+      try {
+        await crowi.ldapUserGroupSyncService?.init(req.user.name, req.body.password);
+      }
+      catch (e) {
+        return res.apiv3Err(
+          new ErrorV3('LDAP group sync failed', 'external_user_group.sync_failed'), 500,
+        );
+      }
+
+      // Do not await for sync to finish. Result (completed, failed) will be notified to the client by socket-io.
+      crowi.ldapUserGroupSyncService?.syncExternalUserGroups();
+
+      return res.apiv3({}, 202);
+    });
 
   /**
    * @swagger
@@ -803,50 +821,51 @@ module.exports = (crowi: Crowi): Router => {
    *                 schema:
    *                 type: object
    */
-  router.put('/keycloak/sync', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    if (isExecutingSync()) {
-      return res.apiv3Err(
-        new ErrorV3('There is an ongoing sync process', 'external_user_group.sync_being_executed'), 409,
-      );
-    }
-
-    const getAuthProviderType = () => {
-      let kcHost = configManager.getConfig('external-user-group:keycloak:host');
-      if (kcHost?.endsWith('/')) {
-        kcHost = kcHost.slice(0, -1);
+  router.put('/keycloak/sync', accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+    async(req: AuthorizedRequest, res: ApiV3Response) => {
+      if (isExecutingSync()) {
+        return res.apiv3Err(
+          new ErrorV3('There is an ongoing sync process', 'external_user_group.sync_being_executed'), 409,
+        );
       }
-      const kcGroupRealm = configManager.getConfig('external-user-group:keycloak:groupRealm');
 
-      // starts with kcHost, contains kcGroupRealm in path
-      // see: https://regex101.com/r/3ihDmf/1
-      const regex = new RegExp(`^${kcHost}/.*/${kcGroupRealm}(/|$).*`);
+      const getAuthProviderType = () => {
+        let kcHost = configManager.getConfig('external-user-group:keycloak:host');
+        if (kcHost?.endsWith('/')) {
+          kcHost = kcHost.slice(0, -1);
+        }
+        const kcGroupRealm = configManager.getConfig('external-user-group:keycloak:groupRealm');
 
-      const isOidcEnabled = configManager.getConfig('security:passport-oidc:isEnabled');
-      const oidcIssuerHost = configManager.getConfig('security:passport-oidc:issuerHost');
+        // starts with kcHost, contains kcGroupRealm in path
+        // see: https://regex101.com/r/3ihDmf/1
+        const regex = new RegExp(`^${kcHost}/.*/${kcGroupRealm}(/|$).*`);
 
-      if (isOidcEnabled && oidcIssuerHost != null && regex.test(oidcIssuerHost)) return 'oidc';
+        const isOidcEnabled = configManager.getConfig('security:passport-oidc:isEnabled');
+        const oidcIssuerHost = configManager.getConfig('security:passport-oidc:issuerHost');
 
-      const isSamlEnabled = configManager.getConfig('security:passport-saml:isEnabled');
-      const samlEntryPoint = configManager.getConfig('security:passport-saml:entryPoint');
+        if (isOidcEnabled && oidcIssuerHost != null && regex.test(oidcIssuerHost)) return 'oidc';
 
-      if (isSamlEnabled && samlEntryPoint != null && regex.test(samlEntryPoint)) return 'saml';
+        const isSamlEnabled = configManager.getConfig('security:passport-saml:isEnabled');
+        const samlEntryPoint = configManager.getConfig('security:passport-saml:entryPoint');
 
-      return null;
-    };
+        if (isSamlEnabled && samlEntryPoint != null && regex.test(samlEntryPoint)) return 'saml';
 
-    const authProviderType = getAuthProviderType();
-    if (authProviderType == null) {
-      return res.apiv3Err(
-        new ErrorV3('Authentication using keycloak is not set', 'external_user_group.keycloak.auth_not_set'), 422,
-      );
-    }
+        return null;
+      };
 
-    crowi.keycloakUserGroupSyncService?.init(authProviderType);
-    // Do not await for sync to finish. Result (completed, failed) will be notified to the client by socket-io.
-    crowi.keycloakUserGroupSyncService?.syncExternalUserGroups();
+      const authProviderType = getAuthProviderType();
+      if (authProviderType == null) {
+        return res.apiv3Err(
+          new ErrorV3('Authentication using keycloak is not set', 'external_user_group.keycloak.auth_not_set'), 422,
+        );
+      }
+
+      crowi.keycloakUserGroupSyncService?.init(authProviderType);
+      // Do not await for sync to finish. Result (completed, failed) will be notified to the client by socket-io.
+      crowi.keycloakUserGroupSyncService?.syncExternalUserGroups();
 
-    return res.apiv3({}, 202);
-  });
+      return res.apiv3({}, 202);
+    });
 
   /**
    * @swagger
@@ -866,10 +885,11 @@ module.exports = (crowi: Crowi): Router => {
    *                 schema:
    *                   $ref: '#/components/schemas/SyncStatus'
    */
-  router.get('/ldap/sync-status', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
-    const syncStatus = crowi.ldapUserGroupSyncService?.syncStatus;
-    return res.apiv3({ ...syncStatus });
-  });
+  router.get('/ldap/sync-status', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+    (req: AuthorizedRequest, res: ApiV3Response) => {
+      const syncStatus = crowi.ldapUserGroupSyncService?.syncStatus;
+      return res.apiv3({ ...syncStatus });
+    });
 
   /**
    * @swagger
@@ -889,10 +909,11 @@ module.exports = (crowi: Crowi): Router => {
    *                 schema:
    *                   $ref: '#/components/schemas/SyncStatus'
    */
-  router.get('/keycloak/sync-status', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
-    const syncStatus = crowi.keycloakUserGroupSyncService?.syncStatus;
-    return res.apiv3({ ...syncStatus });
-  });
+  router.get('/keycloak/sync-status', accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+    (req: AuthorizedRequest, res: ApiV3Response) => {
+      const syncStatus = crowi.keycloakUserGroupSyncService?.syncStatus;
+      return res.apiv3({ ...syncStatus });
+    });
 
   return router;
 

+ 61 - 54
apps/app/src/features/growi-plugin/server/routes/apiv3/admin/index.ts

@@ -1,9 +1,12 @@
-import express, { Request, Router } from 'express';
+import type { Request, Router } from 'express';
+import express from 'express';
 import { body, query } from 'express-validator';
 import mongoose from 'mongoose';
 
-import Crowi from '~/server/crowi';
-import { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+import { SCOPE } from '~/interfaces/scope';
+import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 
 import { GrowiPlugin } from '../../../models';
 import { growiPluginService } from '../../../services';
@@ -29,7 +32,7 @@ module.exports = (crowi: Crowi): Router => {
 
   const router = express.Router();
 
-  router.get('/', loginRequiredStrictly, adminRequired, async(req: Request, res: ApiV3Response) => {
+  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.PLUGIN]), loginRequiredStrictly, adminRequired, async(req: Request, res: ApiV3Response) => {
     try {
       const data = await GrowiPlugin.find({});
       return res.apiv3({ plugins: data });
@@ -39,56 +42,60 @@ module.exports = (crowi: Crowi): Router => {
     }
   });
 
-  router.post('/', loginRequiredStrictly, adminRequired, validator.pluginFormValueisRequired, async(req: Request, res: ApiV3Response) => {
-    const { pluginInstallerForm: formValue } = req.body;
-
-    try {
-      const pluginName = await growiPluginService.install(formValue);
-      return res.apiv3({ pluginName });
-    }
-    catch (err) {
-      return res.apiv3Err(err);
-    }
-  });
-
-  router.put('/:id/activate', loginRequiredStrictly, adminRequired, validator.pluginIdisRequired, async(req: Request, res: ApiV3Response) => {
-    const { id } = req.params;
-    const pluginId = new ObjectID(id);
-
-    try {
-      const pluginName = await GrowiPlugin.activatePlugin(pluginId);
-      return res.apiv3({ pluginName });
-    }
-    catch (err) {
-      return res.apiv3Err(err);
-    }
-  });
-
-  router.put('/:id/deactivate', loginRequiredStrictly, adminRequired, validator.pluginIdisRequired, async(req: Request, res: ApiV3Response) => {
-    const { id } = req.params;
-    const pluginId = new ObjectID(id);
-
-    try {
-      const pluginName = await GrowiPlugin.deactivatePlugin(pluginId);
-      return res.apiv3({ pluginName });
-    }
-    catch (err) {
-      return res.apiv3Err(err);
-    }
-  });
-
-  router.delete('/:id/remove', loginRequiredStrictly, adminRequired, validator.pluginIdisRequired, async(req: Request, res: ApiV3Response) => {
-    const { id } = req.params;
-    const pluginId = new ObjectID(id);
-
-    try {
-      const pluginName = await growiPluginService.deletePlugin(pluginId);
-      return res.apiv3({ pluginName });
-    }
-    catch (err) {
-      return res.apiv3Err(err);
-    }
-  });
+  router.post('/', accessTokenParser([SCOPE.WRITE.ADMIN.PLUGIN]), loginRequiredStrictly, adminRequired, validator.pluginFormValueisRequired,
+    async(req: Request, res: ApiV3Response) => {
+      const { pluginInstallerForm: formValue } = req.body;
+
+      try {
+        const pluginName = await growiPluginService.install(formValue);
+        return res.apiv3({ pluginName });
+      }
+      catch (err) {
+        return res.apiv3Err(err);
+      }
+    });
+
+  router.put('/:id/activate', accessTokenParser([SCOPE.WRITE.ADMIN.PLUGIN]), loginRequiredStrictly, adminRequired, validator.pluginIdisRequired,
+    async(req: Request, res: ApiV3Response) => {
+      const { id } = req.params;
+      const pluginId = new ObjectID(id);
+
+      try {
+        const pluginName = await GrowiPlugin.activatePlugin(pluginId);
+        return res.apiv3({ pluginName });
+      }
+      catch (err) {
+        return res.apiv3Err(err);
+      }
+    });
+
+  router.put('/:id/deactivate', accessTokenParser([SCOPE.WRITE.ADMIN.PLUGIN]), loginRequiredStrictly, adminRequired, validator.pluginIdisRequired,
+    async(req: Request, res: ApiV3Response) => {
+      const { id } = req.params;
+      const pluginId = new ObjectID(id);
+
+      try {
+        const pluginName = await GrowiPlugin.deactivatePlugin(pluginId);
+        return res.apiv3({ pluginName });
+      }
+      catch (err) {
+        return res.apiv3Err(err);
+      }
+    });
+
+  router.delete('/:id/remove', accessTokenParser([SCOPE.WRITE.ADMIN.PLUGIN]), loginRequiredStrictly, adminRequired, validator.pluginIdisRequired,
+    async(req: Request, res: ApiV3Response) => {
+      const { id } = req.params;
+      const pluginId = new ObjectID(id);
+
+      try {
+        const pluginName = await growiPluginService.deletePlugin(pluginId);
+        return res.apiv3({ pluginName });
+      }
+      catch (err) {
+        return res.apiv3Err(err);
+      }
+    });
 
   return router;
 };

+ 2 - 1
apps/app/src/features/openai/server/routes/ai-assistant.ts

@@ -2,6 +2,7 @@ import { type IUserHasId } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
 
+import { SCOPE } from '~/interfaces/scope';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
@@ -28,7 +29,7 @@ export const createAiAssistantFactory: CreateAssistantFactory = (crowi) => {
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
 
   return [
-    accessTokenParser, loginRequiredStrictly, certifyAiService, upsertAiAssistantValidator, apiV3FormValidator,
+    accessTokenParser([SCOPE.WRITE.FEATURES.AI_ASSISTANT]), loginRequiredStrictly, certifyAiService, upsertAiAssistantValidator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
       const openaiService = getOpenaiService();
       if (openaiService == null) {

+ 2 - 1
apps/app/src/features/openai/server/routes/ai-assistants.ts

@@ -2,6 +2,7 @@ import { type IUserHasId } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
 
+import { SCOPE } from '~/interfaces/scope';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
@@ -25,7 +26,7 @@ export const getAiAssistantsFactory: GetAiAssistantsFactory = (crowi) => {
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
 
   return [
-    accessTokenParser, loginRequiredStrictly, certifyAiService,
+    accessTokenParser([SCOPE.READ.FEATURES.AI_ASSISTANT]), loginRequiredStrictly, certifyAiService,
     async(req: Req, res: ApiV3Response) => {
       const openaiService = getOpenaiService();
       if (openaiService == null) {

+ 2 - 1
apps/app/src/features/openai/server/routes/delete-ai-assistant.ts

@@ -5,6 +5,7 @@ import { type ValidationChain, param } from 'express-validator';
 import { isHttpError } from 'http-errors';
 
 
+import { SCOPE } from '~/interfaces/scope';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
@@ -36,7 +37,7 @@ export const deleteAiAssistantsFactory: DeleteAiAssistantsFactory = (crowi) => {
   ];
 
   return [
-    accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
+    accessTokenParser([SCOPE.WRITE.FEATURES.AI_ASSISTANT]), loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
       const { id } = req.params;
       const { user } = req;

+ 2 - 1
apps/app/src/features/openai/server/routes/delete-thread.ts

@@ -5,6 +5,7 @@ import { type ValidationChain, param } from 'express-validator';
 import { isHttpError } from 'http-errors';
 
 
+import { SCOPE } from '~/interfaces/scope';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
@@ -35,7 +36,7 @@ export const deleteThreadFactory: DeleteThreadFactory = (crowi) => {
   ];
 
   return [
-    accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
+    accessTokenParser([SCOPE.WRITE.FEATURES.AI_ASSISTANT]), loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
       const { aiAssistantId, threadRelationId } = req.params;
       const { user } = req;

+ 2 - 1
apps/app/src/features/openai/server/routes/get-messages.ts

@@ -3,6 +3,7 @@ import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
 import { type ValidationChain, param } from 'express-validator';
 
+import { SCOPE } from '~/interfaces/scope';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
@@ -41,7 +42,7 @@ export const getMessagesFactory: GetMessagesFactory = (crowi) => {
   ];
 
   return [
-    accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
+    accessTokenParser([SCOPE.READ.FEATURES.AI_ASSISTANT]), loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
       const openaiService = getOpenaiService();
       if (openaiService == null) {

+ 2 - 1
apps/app/src/features/openai/server/routes/get-threads.ts

@@ -3,6 +3,7 @@ import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
 import { type ValidationChain, param } from 'express-validator';
 
+import { SCOPE } from '~/interfaces/scope';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
@@ -33,7 +34,7 @@ export const getThreadsFactory: GetThreadsFactory = (crowi) => {
   ];
 
   return [
-    accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
+    accessTokenParser([SCOPE.READ.FEATURES.AI_ASSISTANT]), loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
       const openaiService = getOpenaiService();
       if (openaiService == null) {

+ 2 - 1
apps/app/src/features/openai/server/routes/message.ts

@@ -7,6 +7,7 @@ import type { AssistantStream } from 'openai/lib/AssistantStream';
 import type { MessageDelta } from 'openai/resources/beta/threads/messages.mjs';
 
 import { getOrCreateChatAssistant } from '~/features/openai/server/services/assistant';
+import { SCOPE } from '~/interfaces/scope';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
@@ -54,7 +55,7 @@ export const postMessageHandlersFactory: PostMessageHandlersFactory = (crowi) =>
   ];
 
   return [
-    accessTokenParser(), loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
+    accessTokenParser([SCOPE.WRITE.FEATURES.AI_ASSISTANT]), loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
       const { aiAssistantId, threadId } = req.body;
 

+ 2 - 1
apps/app/src/features/openai/server/routes/set-default-ai-assistant.ts

@@ -3,6 +3,7 @@ import type { Request, RequestHandler } from 'express';
 import { type ValidationChain, param, body } from 'express-validator';
 import { isHttpError } from 'http-errors';
 
+import { SCOPE } from '~/interfaces/scope';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
@@ -38,7 +39,7 @@ export const setDefaultAiAssistantFactory: setDefaultAiAssistantFactory = (crowi
   ];
 
   return [
-    accessTokenParser, loginRequiredStrictly, adminRequired, certifyAiService, validator, apiV3FormValidator,
+    accessTokenParser([SCOPE.WRITE.FEATURES.AI_ASSISTANT]), loginRequiredStrictly, adminRequired, certifyAiService, validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
       const openaiService = getOpenaiService();
       if (openaiService == null) {

+ 2 - 1
apps/app/src/features/openai/server/routes/thread.ts

@@ -4,6 +4,7 @@ import type { Request, RequestHandler } from 'express';
 import type { ValidationChain } from 'express-validator';
 import { body } from 'express-validator';
 
+import { SCOPE } from '~/interfaces/scope';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
@@ -34,7 +35,7 @@ export const createThreadHandlersFactory: CreateThreadFactory = (crowi) => {
   ];
 
   return [
-    accessTokenParser(), loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
+    accessTokenParser([SCOPE.WRITE.FEATURES.AI_ASSISTANT]), loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
     async(req: CreateThreadReq, res: ApiV3Response) => {
 
       const openaiService = getOpenaiService();

+ 2 - 1
apps/app/src/features/openai/server/routes/update-ai-assistant.ts

@@ -4,6 +4,7 @@ import type { Request, RequestHandler } from 'express';
 import { type ValidationChain, param } from 'express-validator';
 import { isHttpError } from 'http-errors';
 
+import { SCOPE } from '~/interfaces/scope';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
@@ -39,7 +40,7 @@ export const updateAiAssistantsFactory: UpdateAiAssistantsFactory = (crowi) => {
   ];
 
   return [
-    accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
+    accessTokenParser([SCOPE.WRITE.FEATURES.AI_ASSISTANT]), loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
       const { id } = req.params;
       const { user } = req;

+ 115 - 110
apps/app/src/features/questionnaire/server/routes/apiv3/questionnaire.ts

@@ -3,6 +3,7 @@ import type { Request } from 'express';
 import { Router } from 'express';
 import { body, validationResult } from 'express-validator';
 
+import { SCOPE } from '~/interfaces/scope';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
@@ -61,7 +62,7 @@ module.exports = (crowi: Crowi): Router => {
     return 404;
   };
 
-  router.get('/orders', accessTokenParser(), loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+  router.get('/orders', accessTokenParser([SCOPE.READ.FEATURES.QUESTIONNAIRE]), loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const growiInfo = await growiInfoService.getGrowiInfo(true);
     const userInfo = crowi.questionnaireService.getUserInfo(req.user ?? null, getSiteUrlHashed(growiInfo.appSiteUrl));
 
@@ -76,138 +77,142 @@ module.exports = (crowi: Crowi): Router => {
     }
   });
 
-  router.get('/is-enabled', accessTokenParser(), loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+  router.get('/is-enabled', accessTokenParser([SCOPE.READ.FEATURES.QUESTIONNAIRE]), loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const isEnabled = configManager.getConfig('questionnaire:isQuestionnaireEnabled');
     return res.apiv3({ isEnabled });
   });
 
-  router.post('/proactive/answer', accessTokenParser(), loginRequired, validators.proactiveAnswer, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    const sendQuestionnaireAnswer = async() => {
-      const questionnaireServerOrigin = configManager.getConfig('app:questionnaireServerOrigin');
-      const isAppSiteUrlHashed = configManager.getConfig('questionnaire:isAppSiteUrlHashed');
-      const growiInfo = await growiInfoService.getGrowiInfo(true);
-      const userInfo = crowi.questionnaireService.getUserInfo(req.user ?? null, getSiteUrlHashed(growiInfo.appSiteUrl));
-
-      const proactiveQuestionnaireAnswer: IProactiveQuestionnaireAnswer = {
-        satisfaction: req.body.satisfaction,
-        lengthOfExperience: req.body.lengthOfExperience,
-        position: req.body.position,
-        occupation: req.body.occupation,
-        commentText: req.body.commentText,
-        growiInfo,
-        userInfo,
-        answeredAt: new Date(),
+  router.post('/proactive/answer', accessTokenParser([SCOPE.WRITE.FEATURES.QUESTIONNAIRE]), loginRequired,
+    validators.proactiveAnswer, async(req: AuthorizedRequest, res: ApiV3Response) => {
+      const sendQuestionnaireAnswer = async() => {
+        const questionnaireServerOrigin = configManager.getConfig('app:questionnaireServerOrigin');
+        const isAppSiteUrlHashed = configManager.getConfig('questionnaire:isAppSiteUrlHashed');
+        const growiInfo = await growiInfoService.getGrowiInfo(true);
+        const userInfo = crowi.questionnaireService.getUserInfo(req.user ?? null, getSiteUrlHashed(growiInfo.appSiteUrl));
+
+        const proactiveQuestionnaireAnswer: IProactiveQuestionnaireAnswer = {
+          satisfaction: req.body.satisfaction,
+          lengthOfExperience: req.body.lengthOfExperience,
+          position: req.body.position,
+          occupation: req.body.occupation,
+          commentText: req.body.commentText,
+          growiInfo,
+          userInfo,
+          answeredAt: new Date(),
+        };
+
+        const proactiveQuestionnaireAnswerLegacy = convertToLegacyFormat(proactiveQuestionnaireAnswer, isAppSiteUrlHashed);
+
+        try {
+          await axios.post(`${questionnaireServerOrigin}/questionnaire-answer/proactive`, proactiveQuestionnaireAnswerLegacy);
+        }
+        catch (err) {
+          if (err.request != null) {
+          // when failed to send, save to resend in cronjob
+            await ProactiveQuestionnaireAnswer.create(proactiveQuestionnaireAnswer);
+          }
+          else {
+            throw err;
+          }
+        }
       };
 
-      const proactiveQuestionnaireAnswerLegacy = convertToLegacyFormat(proactiveQuestionnaireAnswer, isAppSiteUrlHashed);
+      const errors = validationResult(req);
+      if (!errors.isEmpty()) {
+        return res.status(400).json({ errors: errors.array() });
+      }
 
       try {
-        await axios.post(`${questionnaireServerOrigin}/questionnaire-answer/proactive`, proactiveQuestionnaireAnswerLegacy);
+        await sendQuestionnaireAnswer();
+        return res.apiv3({});
       }
       catch (err) {
-        if (err.request != null) {
-          // when failed to send, save to resend in cronjob
-          await ProactiveQuestionnaireAnswer.create(proactiveQuestionnaireAnswer);
+        logger.error(err);
+        return res.apiv3Err(err, 500);
+      }
+    });
+
+  router.put('/answer', accessTokenParser([SCOPE.WRITE.FEATURES.QUESTIONNAIRE]), loginRequired,
+    validators.answer, async(req: AuthorizedRequest, res: ApiV3Response) => {
+      const sendQuestionnaireAnswer = async(user: IUserHasId, answers: IAnswer[]) => {
+        const questionnaireServerOrigin = crowi.configManager.getConfig('app:questionnaireServerOrigin');
+        const isAppSiteUrlHashed = configManager.getConfig('questionnaire:isAppSiteUrlHashed');
+        const growiInfo = await growiInfoService.getGrowiInfo(true);
+        const userInfo = crowi.questionnaireService.getUserInfo(user, getSiteUrlHashed(growiInfo.appSiteUrl));
+
+        const questionnaireAnswer: IQuestionnaireAnswer = {
+          growiInfo,
+          userInfo,
+          answers,
+          answeredAt: new Date(),
+          questionnaireOrder: req.body.questionnaireOrderId,
+        };
+
+        const questionnaireAnswerLegacy = convertToLegacyFormat(questionnaireAnswer, isAppSiteUrlHashed);
+
+        try {
+          await axios.post(`${questionnaireServerOrigin}/questionnaire-answer`, questionnaireAnswerLegacy);
         }
-        else {
-          throw err;
+        catch (err) {
+          if (err.request != null) {
+          // when failed to send, save to resend in cronjob
+            await QuestionnaireAnswer.create(questionnaireAnswer);
+          }
+          else {
+            throw err;
+          }
         }
-      }
-    };
-
-    const errors = validationResult(req);
-    if (!errors.isEmpty()) {
-      return res.status(400).json({ errors: errors.array() });
-    }
-
-    try {
-      await sendQuestionnaireAnswer();
-      return res.apiv3({});
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(err, 500);
-    }
-  });
-
-  router.put('/answer', accessTokenParser(), loginRequired, validators.answer, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    const sendQuestionnaireAnswer = async(user: IUserHasId, answers: IAnswer[]) => {
-      const questionnaireServerOrigin = crowi.configManager.getConfig('app:questionnaireServerOrigin');
-      const isAppSiteUrlHashed = configManager.getConfig('questionnaire:isAppSiteUrlHashed');
-      const growiInfo = await growiInfoService.getGrowiInfo(true);
-      const userInfo = crowi.questionnaireService.getUserInfo(user, getSiteUrlHashed(growiInfo.appSiteUrl));
-
-      const questionnaireAnswer: IQuestionnaireAnswer = {
-        growiInfo,
-        userInfo,
-        answers,
-        answeredAt: new Date(),
-        questionnaireOrder: req.body.questionnaireOrderId,
       };
 
-      const questionnaireAnswerLegacy = convertToLegacyFormat(questionnaireAnswer, isAppSiteUrlHashed);
+      const errors = validationResult(req);
+      if (!errors.isEmpty()) {
+        return res.status(400).json({ errors: errors.array() });
+      }
 
       try {
-        await axios.post(`${questionnaireServerOrigin}/questionnaire-answer`, questionnaireAnswerLegacy);
+        await sendQuestionnaireAnswer(req.user ?? null, req.body.answers);
+        const status = await changeAnswerStatus(req.user, req.body.questionnaireOrderId, StatusType.answered);
+        return res.apiv3({}, status);
       }
       catch (err) {
-        if (err.request != null) {
-          // when failed to send, save to resend in cronjob
-          await QuestionnaireAnswer.create(questionnaireAnswer);
-        }
-        else {
-          throw err;
-        }
+        logger.error(err);
+        return res.apiv3Err(err, 500);
       }
-    };
+    });
 
-    const errors = validationResult(req);
-    if (!errors.isEmpty()) {
-      return res.status(400).json({ errors: errors.array() });
-    }
-
-    try {
-      await sendQuestionnaireAnswer(req.user ?? null, req.body.answers);
-      const status = await changeAnswerStatus(req.user, req.body.questionnaireOrderId, StatusType.answered);
-      return res.apiv3({}, status);
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(err, 500);
-    }
-  });
-
-  router.put('/skip', accessTokenParser(), loginRequired, validators.skipDeny, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    const errors = validationResult(req);
-    if (!errors.isEmpty()) {
-      return res.status(400).json({ errors: errors.array() });
-    }
+  router.put('/skip', accessTokenParser([SCOPE.WRITE.FEATURES.QUESTIONNAIRE]), loginRequired,
+    validators.skipDeny, async(req: AuthorizedRequest, res: ApiV3Response) => {
+      const errors = validationResult(req);
+      if (!errors.isEmpty()) {
+        return res.status(400).json({ errors: errors.array() });
+      }
 
-    try {
-      const status = await changeAnswerStatus(req.user, req.body.questionnaireOrderId, StatusType.skipped);
-      return res.apiv3({}, status);
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(err, 500);
-    }
-  });
+      try {
+        const status = await changeAnswerStatus(req.user, req.body.questionnaireOrderId, StatusType.skipped);
+        return res.apiv3({}, status);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err, 500);
+      }
+    });
 
-  router.put('/deny', accessTokenParser(), loginRequired, validators.skipDeny, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    const errors = validationResult(req);
-    if (!errors.isEmpty()) {
-      return res.status(400).json({ errors: errors.array() });
-    }
+  router.put('/deny', accessTokenParser([SCOPE.WRITE.FEATURES.QUESTIONNAIRE]), loginRequired,
+    validators.skipDeny, async(req: AuthorizedRequest, res: ApiV3Response) => {
+      const errors = validationResult(req);
+      if (!errors.isEmpty()) {
+        return res.status(400).json({ errors: errors.array() });
+      }
 
-    try {
-      const status = await changeAnswerStatus(req.user, req.body.questionnaireOrderId, StatusType.denied);
-      return res.apiv3({}, status);
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(err, 500);
-    }
-  });
+      try {
+        const status = await changeAnswerStatus(req.user, req.body.questionnaireOrderId, StatusType.denied);
+        return res.apiv3({}, status);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err, 500);
+      }
+    });
 
   return router;
 

+ 34 - 29
apps/app/src/features/templates/server/routes/apiv3/index.ts

@@ -8,7 +8,9 @@ import { param, query } from 'express-validator';
 
 import { PLUGIN_STORING_PATH } from '~/features/growi-plugin/server/consts';
 import { GrowiPlugin } from '~/features/growi-plugin/server/models';
+import { SCOPE } from '~/interfaces/scope';
 import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
@@ -36,7 +38,7 @@ let presetTemplateSummaries: TemplateSummary[];
 module.exports = (crowi: Crowi) => {
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
 
-  router.get('/', loginRequiredStrictly, validator.list, apiV3FormValidator, async(req, res: ApiV3Response) => {
+  router.get('/', accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequiredStrictly, validator.list, apiV3FormValidator, async(req, res: ApiV3Response) => {
     const { includeInvalidTemplates } = req.query;
 
     // scan preset templates
@@ -72,39 +74,42 @@ module.exports = (crowi: Crowi) => {
     });
   });
 
-  router.get('/preset-templates/:templateId/:locale', loginRequiredStrictly, validator.get, apiV3FormValidator, async(req, res: ApiV3Response) => {
-    const {
-      templateId, locale,
-    } = req.params;
+  router.get('/preset-templates/:templateId/:locale', accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequiredStrictly,
+    validator.get, apiV3FormValidator,
+    async(req, res: ApiV3Response) => {
+      const {
+        templateId, locale,
+      } = req.params;
 
-    const presetTemplatesRoot = resolveFromRoot('node_modules/@growi/preset-templates');
+      const presetTemplatesRoot = resolveFromRoot('node_modules/@growi/preset-templates');
 
-    try {
-      const markdown = await getMarkdown(presetTemplatesRoot, templateId, locale);
-      return res.apiv3({ markdown });
-    }
-    catch (err) {
-      res.apiv3Err(err);
-    }
-  });
+      try {
+        const markdown = await getMarkdown(presetTemplatesRoot, templateId, locale);
+        return res.apiv3({ markdown });
+      }
+      catch (err) {
+        res.apiv3Err(err);
+      }
+    });
 
-  router.get('/plugin-templates/:organizationId/:reposId/:templateId/:locale', loginRequiredStrictly, validator.get, apiV3FormValidator, async(
-      req, res: ApiV3Response,
-  ) => {
-    const {
-      organizationId, reposId, templateId, locale,
-    } = req.params;
+  router.get('/plugin-templates/:organizationId/:reposId/:templateId/:locale', accessTokenParser([SCOPE.READ.FEATURES.PAGE]),
+    loginRequiredStrictly, validator.get, apiV3FormValidator, async(
+        req, res: ApiV3Response,
+    ) => {
+      const {
+        organizationId, reposId, templateId, locale,
+      } = req.params;
 
-    const pluginRoot = path.join(PLUGIN_STORING_PATH, `${organizationId}/${reposId}`);
+      const pluginRoot = path.join(PLUGIN_STORING_PATH, `${organizationId}/${reposId}`);
 
-    try {
-      const markdown = await getMarkdown(pluginRoot, templateId, locale);
-      return res.apiv3({ markdown });
-    }
-    catch (err) {
-      res.apiv3Err(err);
-    }
-  });
+      try {
+        const markdown = await getMarkdown(pluginRoot, templateId, locale);
+        return res.apiv3({ markdown });
+      }
+      catch (err) {
+        res.apiv3Err(err);
+      }
+    });
 
   return router;
 };

+ 27 - 16
apps/app/src/interfaces/scope.ts

@@ -1,5 +1,10 @@
-// If you want to add a new scope, you only need to add a new key to the ORIGINAL_SCOPE object.
-export const ORIGINAL_SCOPE_ADMIN = {
+// SCOPE_SEED defines the basic scope structure.
+// When you need to set different permissions for Admin and User
+// on specific endpoints (like /me), use SCOPE rather than modifying SCOPE_SEED.
+
+// If you want to add a new scope, you only need to add a new key to the SCOPE_SEED object.
+
+const SCOPE_SEED_ADMIN = {
   admin: {
     top: {},
     app: {},
@@ -21,8 +26,8 @@ export const ORIGINAL_SCOPE_ADMIN = {
   },
 } as const;
 
-export const ORIGINAL_SCOPE_USER = {
-  user: {
+const SCOPE_SEED_USER = {
+  user_settings: {
     info: {},
     external_account: {},
     password: {},
@@ -33,13 +38,19 @@ export const ORIGINAL_SCOPE_USER = {
     in_app_notification: {},
     other: {},
   },
-  base: {
+  features: {
+    ai_assistant: {},
+    page: {},
+    share_link: {},
+    bookmark: {},
+    questionnaire: {},
+    attachment: {},
   },
 } as const;
 
-export const ORIGINAL_SCOPE = {
-  ...ORIGINAL_SCOPE_ADMIN,
-  ...ORIGINAL_SCOPE_USER,
+const SCOPE_SEED = {
+  ...SCOPE_SEED_ADMIN,
+  ...SCOPE_SEED_USER,
 } as const;
 
 export const ACTION = {
@@ -50,12 +61,12 @@ export const ACTION = {
 type ACTION_TYPE = typeof ACTION[keyof typeof ACTION];
 export const ALL_SIGN = '*';
 
-export const ORIGINAL_SCOPE_WITH_ACTION = Object.values(ACTION).reduce(
+const SCOPE_SEED_WITH_ACTION = Object.values(ACTION).reduce(
   (acc, action) => {
-    acc[action] = ORIGINAL_SCOPE;
+    acc[action] = SCOPE_SEED;
     return acc;
   },
-  {} as Record<ACTION_TYPE, typeof ORIGINAL_SCOPE>,
+  {} as Record<ACTION_TYPE, typeof SCOPE_SEED>,
 );
 
 type FlattenObject<T> = {
@@ -71,11 +82,11 @@ type AddAllToScope<S extends string> =
     ? `${X}:${typeof ALL_SIGN}` | `${X}:${AddAllToScope<Y>}` | S
     : S;
 
-type ScopeOnly = FlattenObject<typeof ORIGINAL_SCOPE_WITH_ACTION>;
+type ScopeOnly = FlattenObject<typeof SCOPE_SEED_WITH_ACTION>;
 type ScopeWithAll = AddAllToScope<ScopeOnly>;
 export type Scope = ScopeOnly | ScopeWithAll;
 
-// ScopeConstantsの型定義
+// ScopeConstants type definition
 type ScopeConstantLeaf = Scope;
 
 type ScopeConstantNode<T> = {
@@ -87,8 +98,8 @@ type ScopeConstantNode<T> = {
 };
 
 type ScopeConstantType = {
-  [A in keyof typeof ORIGINAL_SCOPE_WITH_ACTION as Uppercase<string & A>]:
-    ScopeConstantNode<typeof ORIGINAL_SCOPE> & { ALL: Scope }
+  [A in keyof typeof SCOPE_SEED_WITH_ACTION as Uppercase<string & A>]:
+    ScopeConstantNode<typeof SCOPE_SEED> & { ALL: Scope }
 };
 
 const buildScopeConstants = (): ScopeConstantType => {
@@ -116,7 +127,7 @@ const buildScopeConstants = (): ScopeConstantType => {
       }
     });
   };
-  processObject(ORIGINAL_SCOPE_WITH_ACTION, [], result);
+  processObject(SCOPE_SEED_WITH_ACTION, [], result);
 
   return result as ScopeConstantType;
 };

+ 8 - 14
apps/app/src/server/middlewares/access-token-parser/access-token.integ.ts

@@ -43,7 +43,6 @@ describe('access-token-parser middleware for access token with scopes', () => {
     await parserForAccessToken([])(reqMock, resMock, nextMock);
 
     expect(reqMock.user).toBeUndefined();
-    expect(nextMock).toHaveBeenCalled();
   });
 
   it('should not authenticate with no scopes', async() => {
@@ -77,7 +76,6 @@ describe('access-token-parser middleware for access token with scopes', () => {
     // assert
     expect(reqMock.user).toBeUndefined();
     expect(serializeUserSecurely).not.toHaveBeenCalled();
-    expect(nextMock).not.toHaveBeenCalled();
   });
 
   it('should authenticate with specific scope', async() => {
@@ -102,18 +100,17 @@ describe('access-token-parser middleware for access token with scopes', () => {
     const { token } = await AccessToken.generateToken(
       targetUser._id,
       new Date(Date.now() + 1000 * 60 * 60 * 24),
-      [SCOPE.READ.USER.INFO],
+      [SCOPE.READ.USER_SETTINGS.INFO],
     );
 
     // act
     reqMock.query.access_token = token;
-    await parserForAccessToken([SCOPE.READ.USER.INFO])(reqMock, resMock, nextMock);
+    await parserForAccessToken([SCOPE.READ.USER_SETTINGS.INFO])(reqMock, resMock, nextMock);
 
     // assert
     expect(reqMock.user).toBeDefined();
     expect(reqMock.user?._id).toStrictEqual(targetUser._id);
     expect(serializeUserSecurely).toHaveBeenCalledOnce();
-    expect(nextMock).toHaveBeenCalled();
   });
 
   it('should reject with insufficient scopes', async() => {
@@ -139,17 +136,16 @@ describe('access-token-parser middleware for access token with scopes', () => {
     const { token } = await AccessToken.generateToken(
       targetUser._id,
       new Date(Date.now() + 1000 * 60 * 60 * 24),
-      [SCOPE.READ.USER.INFO],
+      [SCOPE.READ.USER_SETTINGS.INFO],
     );
 
     // act - try to access with write:user:info scope
     reqMock.query.access_token = token;
-    await parserForAccessToken([SCOPE.WRITE.USER.INFO])(reqMock, resMock, nextMock);
+    await parserForAccessToken([SCOPE.WRITE.USER_SETTINGS.INFO])(reqMock, resMock, nextMock);
 
     // // assert
     expect(reqMock.user).toBeUndefined();
     expect(serializeUserSecurely).not.toHaveBeenCalled();
-    expect(nextMock).not.toHaveBeenCalled();
   });
 
   it('should authenticate with write scope implying read scope', async() => {
@@ -174,18 +170,17 @@ describe('access-token-parser middleware for access token with scopes', () => {
     const { token } = await AccessToken.generateToken(
       targetUser._id,
       new Date(Date.now() + 1000 * 60 * 60 * 24),
-      [SCOPE.WRITE.USER.INFO],
+      [SCOPE.WRITE.USER_SETTINGS.INFO],
     );
 
     // act - try to access with read:user:info scope
     reqMock.query.access_token = token;
-    await parserForAccessToken([SCOPE.READ.USER.INFO])(reqMock, resMock, nextMock);
+    await parserForAccessToken([SCOPE.READ.USER_SETTINGS.INFO])(reqMock, resMock, nextMock);
 
     // assert
     expect(reqMock.user).toBeDefined();
     expect(reqMock.user?._id).toStrictEqual(targetUser._id);
     expect(serializeUserSecurely).toHaveBeenCalledOnce();
-    expect(nextMock).toHaveBeenCalled();
   });
 
   it('should authenticate with wildcard scope', async() => {
@@ -208,18 +203,17 @@ describe('access-token-parser middleware for access token with scopes', () => {
     const { token } = await AccessToken.generateToken(
       targetUser._id,
       new Date(Date.now() + 1000 * 60 * 60 * 24),
-      [SCOPE.READ.USER.ALL],
+      [SCOPE.READ.USER_SETTINGS.ALL],
     );
 
     // act - try to access with read:user:info scope
     reqMock.query.access_token = token;
-    await parserForAccessToken([SCOPE.READ.USER.INFO, SCOPE.READ.USER.API.ACCESS_TOKEN])(reqMock, resMock, nextMock);
+    await parserForAccessToken([SCOPE.READ.USER_SETTINGS.INFO, SCOPE.READ.USER_SETTINGS.API.ACCESS_TOKEN])(reqMock, resMock, nextMock);
 
     // assert
     expect(reqMock.user).toBeDefined();
     expect(reqMock.user?._id).toStrictEqual(targetUser._id);
     expect(serializeUserSecurely).toHaveBeenCalledOnce();
-    expect(nextMock).toHaveBeenCalled();
   });
 
 });

+ 2 - 2
apps/app/src/server/middlewares/access-token-parser/access-token.ts

@@ -15,7 +15,7 @@ export const parserForAccessToken = (scopes: Scope[]) => {
 
     const accessToken = req.query.access_token ?? req.body.access_token;
     if (accessToken == null || typeof accessToken !== 'string') {
-      return next();
+      return;
     }
     if (scopes == null || scopes.length === 0) {
       logger.debug('scopes is empty');
@@ -43,7 +43,7 @@ export const parserForAccessToken = (scopes: Scope[]) => {
     }
 
     logger.debug('Access token parsed.');
-    return next();
+    return;
 
   };
 };

+ 0 - 4
apps/app/src/server/middlewares/access-token-parser/api-token.integ.ts

@@ -48,7 +48,6 @@ describe('access-token-parser middleware', () => {
     // assert
     expect(reqMock.user).toBeUndefined();
     expect(serializeUserSecurely).not.toHaveBeenCalled();
-    expect(nextMock).toHaveBeenCalled();
   });
 
   it('should call next if the given access token is invalid', async() => {
@@ -68,7 +67,6 @@ describe('access-token-parser middleware', () => {
     // assert
     expect(reqMock.user).toBeUndefined();
     expect(serializeUserSecurely).not.toHaveBeenCalled();
-    expect(nextMock).not.toHaveBeenCalled();
   });
 
   it('should set req.user with a valid api token in query', async() => {
@@ -98,7 +96,6 @@ describe('access-token-parser middleware', () => {
     expect(reqMock.user).toBeDefined();
     expect(reqMock.user?._id).toStrictEqual(targetUser._id);
     expect(serializeUserSecurely).toHaveBeenCalledOnce();
-    expect(nextMock).toHaveBeenCalled();
   });
 
   it('should set req.user with a valid api token in body', async() => {
@@ -128,7 +125,6 @@ describe('access-token-parser middleware', () => {
     expect(reqMock.user).toBeDefined();
     expect(reqMock.user?._id).toStrictEqual(targetUser._id);
     expect(serializeUserSecurely).toHaveBeenCalledOnce();
-    expect(nextMock).toHaveBeenCalled();
   });
 
 });

+ 2 - 2
apps/app/src/server/middlewares/access-token-parser/api-token.ts

@@ -13,7 +13,7 @@ const logger = loggerFactory('growi:middleware:access-token-parser:api-token');
 export const parserForApiToken = async(req: AccessTokenParserReq, res: Response, next: NextFunction): Promise<void> => {
   const accessToken = req.query.access_token ?? req.body.access_token;
   if (accessToken == null || typeof accessToken !== 'string') {
-    return next();
+    return;
   }
 
   logger.debug('accessToken is', accessToken);
@@ -31,5 +31,5 @@ export const parserForApiToken = async(req: AccessTokenParserReq, res: Response,
   }
 
   logger.debug('Access token parsed.');
-  return next();
+  return;
 };

+ 2 - 2
apps/app/src/server/middlewares/access-token-parser/index.ts

@@ -11,9 +11,9 @@ export const accessTokenParser = (scopes?: Scope[]) => {
     // TODO: comply HTTP header of RFC6750 / Authorization: Bearer
 
     if (scopes != null) {
-      parserForAccessToken(scopes)(req, res, next);
+      await parserForAccessToken(scopes)(req, res, next);
     }
-    parserForApiToken(req, res, next);
+    await parserForApiToken(req, res, next);
 
     return next();
   };

+ 2 - 1
apps/app/src/server/routes/apiv3/activity.ts

@@ -5,6 +5,7 @@ import express from 'express';
 import { query } from 'express-validator';
 
 import type { IActivity, ISearchFilter } from '~/interfaces/activity';
+import { SCOPE } from '~/interfaces/scope';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import Activity from '~/server/models/activity';
 import { configManager } from '~/server/service/config-manager';
@@ -34,7 +35,7 @@ module.exports = (crowi: Crowi): Router => {
   const router = express.Router();
 
   // eslint-disable-next-line max-len
-  router.get('/', accessTokenParser(), loginRequiredStrictly, adminRequired, validator.list, apiV3FormValidator, async(req: Request, res: ApiV3Response) => {
+  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.AUDIT_LOG]), loginRequiredStrictly, adminRequired, validator.list, apiV3FormValidator, async(req: Request, res: ApiV3Response) => {
     const auditLogEnabled = configManager.getConfig('app:auditLogEnabled');
     if (!auditLogEnabled) {
       const msg = 'AuditLog is not enabled';

+ 3 - 1
apps/app/src/server/routes/apiv3/admin-home.ts

@@ -1,3 +1,5 @@
+import { SCOPE } from '~/interfaces/scope';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { configManager } from '~/server/service/config-manager';
 import { getGrowiVersion } from '~/utils/growi-version';
 
@@ -83,7 +85,7 @@ module.exports = (crowi) => {
    *                    adminHomeParams:
    *                      $ref: "#/components/schemas/SystemInformationParams"
    */
-  router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.TOP]), loginRequiredStrictly, adminRequired, async(req, res) => {
     const { getRuntimeVersions } = await import('~/server/util/runtime-versions');
     const runtimeVersions = await getRuntimeVersions();
 

+ 110 - 102
apps/app/src/server/routes/apiv3/app-settings.js

@@ -5,6 +5,7 @@ import { body } from 'express-validator';
 import { i18n } from '^/config/next-i18next.config';
 
 import { SupportedAction } from '~/interfaces/activity';
+import { SCOPE } from '~/interfaces/scope';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { configManager } from '~/server/service/config-manager';
 import { getTranslation } from '~/server/service/i18next';
@@ -434,7 +435,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      $ref: '#/components/schemas/AppSettingParams'
    */
-  router.get('/', accessTokenParser(), loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.APP]), loginRequiredStrictly, adminRequired, async(req, res) => {
     const appSettingsParams = {
       title: configManager.getConfig('app:title'),
       confidential: configManager.getConfig('app:confidential'),
@@ -537,37 +538,39 @@ module.exports = (crowi) => {
    *                      type: object
    *                      $ref: '#/components/schemas/AppSettingPutParams'
    */
-  router.put('/app-setting', loginRequiredStrictly, adminRequired, addActivity, validator.appSetting, apiV3FormValidator, async(req, res) => {
-    const requestAppSettingParams = {
-      'app:title': req.body.title,
-      'app:confidential': req.body.confidential,
-      'app:globalLang': req.body.globalLang,
-      'customize:isEmailPublishedForNewUser': req.body.isEmailPublishedForNewUser,
-      'app:fileUpload': req.body.fileUpload,
-    };
-
-    try {
-      await configManager.updateConfigs(requestAppSettingParams);
-      const appSettingParams = {
-        title: configManager.getConfig('app:title'),
-        confidential: configManager.getConfig('app:confidential'),
-        globalLang: configManager.getConfig('app:globalLang'),
-        isEmailPublishedForNewUser: configManager.getConfig('customize:isEmailPublishedForNewUser'),
-        fileUpload: configManager.getConfig('app:fileUpload'),
+  router.put('/app-setting', accessTokenParser([SCOPE.WRITE.ADMIN.APP]), loginRequiredStrictly, adminRequired, addActivity,
+    validator.appSetting, apiV3FormValidator,
+    async(req, res) => {
+      const requestAppSettingParams = {
+        'app:title': req.body.title,
+        'app:confidential': req.body.confidential,
+        'app:globalLang': req.body.globalLang,
+        'customize:isEmailPublishedForNewUser': req.body.isEmailPublishedForNewUser,
+        'app:fileUpload': req.body.fileUpload,
       };
 
-      const parameters = { action: SupportedAction.ACTION_ADMIN_APP_SETTINGS_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
+      try {
+        await configManager.updateConfigs(requestAppSettingParams);
+        const appSettingParams = {
+          title: configManager.getConfig('app:title'),
+          confidential: configManager.getConfig('app:confidential'),
+          globalLang: configManager.getConfig('app:globalLang'),
+          isEmailPublishedForNewUser: configManager.getConfig('customize:isEmailPublishedForNewUser'),
+          fileUpload: configManager.getConfig('app:fileUpload'),
+        };
 
-      return res.apiv3({ appSettingParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating app setting';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-appSetting-failed'));
-    }
+        const parameters = { action: SupportedAction.ACTION_ADMIN_APP_SETTINGS_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
 
-  });
+        return res.apiv3({ appSettingParams });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating app setting';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-appSetting-failed'));
+      }
+
+    });
 
   /**
    * @swagger
@@ -602,36 +605,37 @@ module.exports = (crowi) => {
    *                          description: Site URL. e.g. https://example.com, https://example.com:3000
    *                          example: 'http://localhost:3000'
    */
-  router.put('/site-url-setting', loginRequiredStrictly, adminRequired, addActivity, validator.siteUrlSetting, apiV3FormValidator, async(req, res) => {
-
-    const useOnlyEnvVars = configManager.getConfig('env:useOnlyEnvVars:app:siteUrl');
-
-    if (useOnlyEnvVars) {
-      const msg = 'Updating the Site URL is prohibited on this system.';
-      return res.apiv3Err(new ErrorV3(msg, 'update-siteUrlSetting-prohibited'));
-    }
+  router.put('/site-url-setting', accessTokenParser([SCOPE.WRITE.ADMIN.APP]), loginRequiredStrictly, adminRequired, addActivity,
+    validator.siteUrlSetting, apiV3FormValidator,
+    async(req, res) => {
+      const useOnlyEnvVars = configManager.getConfig('env:useOnlyEnvVars:app:siteUrl');
 
-    const requestSiteUrlSettingParams = {
-      'app:siteUrl': pathUtils.removeTrailingSlash(req.body.siteUrl),
-    };
+      if (useOnlyEnvVars) {
+        const msg = 'Updating the Site URL is prohibited on this system.';
+        return res.apiv3Err(new ErrorV3(msg, 'update-siteUrlSetting-prohibited'));
+      }
 
-    try {
-      await configManager.updateConfigs(requestSiteUrlSettingParams);
-      const siteUrlSettingParams = {
-        siteUrl: configManager.getConfig('app:siteUrl'),
+      const requestSiteUrlSettingParams = {
+        'app:siteUrl': pathUtils.removeTrailingSlash(req.body.siteUrl),
       };
 
-      const parameters = { action: SupportedAction.ACTION_ADMIN_SITE_URL_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-      return res.apiv3({ siteUrlSettingParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating site url setting';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-siteUrlSetting-failed'));
-    }
+      try {
+        await configManager.updateConfigs(requestSiteUrlSettingParams);
+        const siteUrlSettingParams = {
+          siteUrl: configManager.getConfig('app:siteUrl'),
+        };
 
-  });
+        const parameters = { action: SupportedAction.ACTION_ADMIN_SITE_URL_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+        return res.apiv3({ siteUrlSettingParams });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating site url setting';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-siteUrlSetting-failed'));
+      }
+
+    });
 
   /**
    * send mail (Promise wrapper)
@@ -749,28 +753,30 @@ module.exports = (crowi) => {
    *                      type: object
    *                      $ref: '#/components/schemas/SmtpSettingResponseParams'
    */
-  router.put('/smtp-setting', loginRequiredStrictly, adminRequired, addActivity, validator.smtpSetting, apiV3FormValidator, async(req, res) => {
-    const requestMailSettingParams = {
-      'mail:from': req.body.fromAddress,
-      'mail:transmissionMethod': req.body.transmissionMethod,
-      'mail:smtpHost': req.body.smtpHost,
-      'mail:smtpPort': req.body.smtpPort,
-      'mail:smtpUser': req.body.smtpUser,
-      'mail:smtpPassword': req.body.smtpPassword,
-    };
+  router.put('/smtp-setting', accessTokenParser([SCOPE.WRITE.ADMIN.APP]), loginRequiredStrictly, adminRequired, addActivity,
+    validator.smtpSetting, apiV3FormValidator,
+    async(req, res) => {
+      const requestMailSettingParams = {
+        'mail:from': req.body.fromAddress,
+        'mail:transmissionMethod': req.body.transmissionMethod,
+        'mail:smtpHost': req.body.smtpHost,
+        'mail:smtpPort': req.body.smtpPort,
+        'mail:smtpUser': req.body.smtpUser,
+        'mail:smtpPassword': req.body.smtpPassword,
+      };
 
-    try {
-      const mailSettingParams = await updateMailSettinConfig(requestMailSettingParams);
-      const parameters = { action: SupportedAction.ACTION_ADMIN_MAIL_SMTP_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-      return res.apiv3({ mailSettingParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating smtp setting';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-smtpSetting-failed'));
-    }
-  });
+      try {
+        const mailSettingParams = await updateMailSettinConfig(requestMailSettingParams);
+        const parameters = { action: SupportedAction.ACTION_ADMIN_MAIL_SMTP_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+        return res.apiv3({ mailSettingParams });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating smtp setting';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-smtpSetting-failed'));
+      }
+    });
 
   /**
    * @swagger
@@ -792,7 +798,7 @@ module.exports = (crowi) => {
    *                  type: object
    *                  description: Empty object
    */
-  router.post('/smtp-test', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
+  router.post('/smtp-test', accessTokenParser([SCOPE.WRITE.ADMIN.APP]), loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
     const { t } = await getTranslation({ lang: req.user.lang });
 
     try {
@@ -834,32 +840,34 @@ module.exports = (crowi) => {
    *                schema:
    *                  $ref: '#/components/schemas/SesSettingResponseParams'
    */
-  router.put('/ses-setting', loginRequiredStrictly, adminRequired, addActivity, validator.sesSetting, apiV3FormValidator, async(req, res) => {
-    const { mailService } = crowi;
+  router.put('/ses-setting', accessTokenParser([SCOPE.WRITE.ADMIN.APP]), loginRequiredStrictly, adminRequired, addActivity,
+    validator.sesSetting, apiV3FormValidator,
+    async(req, res) => {
+      const { mailService } = crowi;
 
-    const requestSesSettingParams = {
-      'mail:from': req.body.fromAddress,
-      'mail:transmissionMethod': req.body.transmissionMethod,
-      'mail:sesAccessKeyId': req.body.sesAccessKeyId,
-      'mail:sesSecretAccessKey': req.body.sesSecretAccessKey,
-    };
+      const requestSesSettingParams = {
+        'mail:from': req.body.fromAddress,
+        'mail:transmissionMethod': req.body.transmissionMethod,
+        'mail:sesAccessKeyId': req.body.sesAccessKeyId,
+        'mail:sesSecretAccessKey': req.body.sesSecretAccessKey,
+      };
 
-    let mailSettingParams;
-    try {
-      mailSettingParams = await updateMailSettinConfig(requestSesSettingParams);
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating ses setting';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-ses-setting-failed'));
-    }
+      let mailSettingParams;
+      try {
+        mailSettingParams = await updateMailSettinConfig(requestSesSettingParams);
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating ses setting';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-ses-setting-failed'));
+      }
 
-    await mailService.initialize();
-    mailService.publishUpdatedMessage();
-    const parameters = { action: SupportedAction.ACTION_ADMIN_MAIL_SES_UPDATE };
-    activityEvent.emit('update', res.locals.activity._id, parameters);
-    return res.apiv3({ mailSettingParams });
-  });
+      await mailService.initialize();
+      mailService.publishUpdatedMessage();
+      const parameters = { action: SupportedAction.ACTION_ADMIN_MAIL_SES_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+      return res.apiv3({ mailSettingParams });
+    });
 
   /**
    * @swagger
@@ -891,7 +899,7 @@ module.exports = (crowi) => {
    *                      $ref: '#/components/schemas/FileUploadSettingParams'
    */
   //  eslint-disable-next-line max-len
-  router.put('/file-upload-setting', loginRequiredStrictly, adminRequired, addActivity, validator.fileUploadSetting, apiV3FormValidator, async(req, res) => {
+  router.put('/file-upload-setting', accessTokenParser([SCOPE.WRITE.ADMIN.APP]), loginRequiredStrictly, adminRequired, addActivity, validator.fileUploadSetting, apiV3FormValidator, async(req, res) => {
     const { fileUploadType } = req.body;
 
     const requestParams = {
@@ -1002,7 +1010,7 @@ module.exports = (crowi) => {
    *                      $ref: '#/components/schemas/QuestionnaireSettingParams'
    */
   // eslint-disable-next-line max-len
-  router.put('/questionnaire-settings', loginRequiredStrictly, adminRequired, addActivity, validator.questionnaireSettings, apiV3FormValidator, async(req, res) => {
+  router.put('/questionnaire-settings', accessTokenParser([SCOPE.WRITE.ADMIN.APP]), loginRequiredStrictly, adminRequired, addActivity, validator.questionnaireSettings, apiV3FormValidator, async(req, res) => {
     const { isQuestionnaireEnabled, isAppSiteUrlHashed } = req.body;
 
     const requestParams = {
@@ -1081,7 +1089,7 @@ module.exports = (crowi) => {
    *                      description: is V5 compatible, or not
    *                      example: true
    */
-  router.post('/v5-schema-migration', accessTokenParser(), loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.post('/v5-schema-migration', accessTokenParser([SCOPE.WRITE.ADMIN.APP]), loginRequiredStrictly, adminRequired, async(req, res) => {
     const isMaintenanceMode = crowi.appService.isMaintenanceMode();
     if (!isMaintenanceMode) {
       return res.apiv3Err(new ErrorV3('GROWI is not maintenance mode. To import data, please activate the maintenance mode first.', 'not_maintenance_mode'));
@@ -1136,7 +1144,7 @@ module.exports = (crowi) => {
    *                      example: true
    */
   // eslint-disable-next-line max-len
-  router.post('/maintenance-mode', accessTokenParser(), loginRequiredStrictly, adminRequired, addActivity, validator.maintenanceMode, apiV3FormValidator, async(req, res) => {
+  router.post('/maintenance-mode', accessTokenParser([SCOPE.WRITE.ADMIN.APP]), loginRequiredStrictly, adminRequired, addActivity, validator.maintenanceMode, apiV3FormValidator, async(req, res) => {
     const { flag } = req.body;
     const parameters = {};
     try {

+ 52 - 48
apps/app/src/server/routes/apiv3/attachment.js

@@ -5,6 +5,7 @@ import multer from 'multer';
 import autoReap from 'multer-autoreap';
 
 import { SupportedAction } from '~/interfaces/activity';
+import { SCOPE } from '~/interfaces/scope';
 import { AttachmentType } from '~/server/interfaces/attachment';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { Attachment } from '~/server/models/attachment';
@@ -198,45 +199,46 @@ module.exports = (crowi) => {
    *                  type: object
    *                  $ref: '#/components/schemas/AttachmentPaginateResult'
    */
-  router.get('/list', accessTokenParser(), loginRequired, validator.retrieveAttachments, apiV3FormValidator, async(req, res) => {
-
-    const limit = req.query.limit || await crowi.configManager.getConfig('customize:showPageLimitationS') || 10;
-    const pageNumber = req.query.pageNumber || 1;
-    const offset = (pageNumber - 1) * limit;
-
-    try {
-      const pageId = req.query.pageId;
-      // check whether accessible
-      const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
-      if (!isAccessible) {
-        const msg = 'Current user is not accessible to this page.';
-        return res.apiv3Err(new ErrorV3(msg, 'attachment-list-failed'), 403);
-      }
-
-      // directly get paging-size from db. not to delivery from client side.
+  router.get('/list', accessTokenParser([SCOPE.READ.FEATURES.ATTACHMENT]), loginRequired, validator.retrieveAttachments, apiV3FormValidator,
+    async(req, res) => {
 
-      const paginateResult = await Attachment.paginate(
-        { page: pageId },
-        {
-          limit,
-          offset,
-          populate: 'creator',
-        },
-      );
+      const limit = req.query.limit || await crowi.configManager.getConfig('customize:showPageLimitationS') || 10;
+      const pageNumber = req.query.pageNumber || 1;
+      const offset = (pageNumber - 1) * limit;
 
-      paginateResult.docs.forEach((doc) => {
-        if (doc.creator != null && doc.creator instanceof User) {
-          doc.creator = serializeUserSecurely(doc.creator);
+      try {
+        const pageId = req.query.pageId;
+        // check whether accessible
+        const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
+        if (!isAccessible) {
+          const msg = 'Current user is not accessible to this page.';
+          return res.apiv3Err(new ErrorV3(msg, 'attachment-list-failed'), 403);
         }
-      });
 
-      return res.apiv3({ paginateResult });
-    }
-    catch (err) {
-      logger.error('Attachment not found', err);
-      return res.apiv3Err(err, 500);
-    }
-  });
+        // directly get paging-size from db. not to delivery from client side.
+
+        const paginateResult = await Attachment.paginate(
+          { page: pageId },
+          {
+            limit,
+            offset,
+            populate: 'creator',
+          },
+        );
+
+        paginateResult.docs.forEach((doc) => {
+          if (doc.creator != null && doc.creator instanceof User) {
+            doc.creator = serializeUserSecurely(doc.creator);
+          }
+        });
+
+        return res.apiv3({ paginateResult });
+      }
+      catch (err) {
+        logger.error('Attachment not found', err);
+        return res.apiv3Err(err, 500);
+      }
+    });
 
 
   /**
@@ -272,17 +274,18 @@ module.exports = (crowi) => {
    *          500:
    *            $ref: '#/components/responses/500'
    */
-  router.get('/limit', accessTokenParser(), loginRequiredStrictly, validator.retrieveFileLimit, apiV3FormValidator, async(req, res) => {
-    const { fileUploadService } = crowi;
-    const fileSize = Number(req.query.fileSize);
-    try {
-      return res.apiv3(await fileUploadService.checkLimit(fileSize));
-    }
-    catch (err) {
-      logger.error('File limit retrieval failed', err);
-      return res.apiv3Err(err, 500);
-    }
-  });
+  router.get('/limit', accessTokenParser([SCOPE.READ.FEATURES.ATTACHMENT]), loginRequiredStrictly, validator.retrieveFileLimit, apiV3FormValidator,
+    async(req, res) => {
+      const { fileUploadService } = crowi;
+      const fileSize = Number(req.query.fileSize);
+      try {
+        return res.apiv3(await fileUploadService.checkLimit(fileSize));
+      }
+      catch (err) {
+        logger.error('File limit retrieval failed', err);
+        return res.apiv3Err(err, 500);
+      }
+    });
 
   /**
    * @swagger
@@ -339,7 +342,7 @@ module.exports = (crowi) => {
    *          500:
    *            $ref: '#/components/responses/500'
    */
-  router.post('/', uploads.single('file'), autoReap, accessTokenParser(), loginRequiredStrictly, excludeReadOnlyUser,
+  router.post('/', uploads.single('file'), autoReap, accessTokenParser([SCOPE.WRITE.FEATURES.ATTACHMENT]), loginRequiredStrictly, excludeReadOnlyUser,
     validator.retrieveAddAttachment, apiV3FormValidator, addActivity,
     async(req, res) => {
 
@@ -403,7 +406,8 @@ module.exports = (crowi) => {
    *            schema:
    *              type: string
    */
-  router.get('/:id', accessTokenParser(), certifySharedPageAttachmentMiddleware, loginRequired, validator.retrieveAttachment, apiV3FormValidator,
+  router.get('/:id', accessTokenParser([SCOPE.READ.FEATURES.ATTACHMENT]), certifySharedPageAttachmentMiddleware, loginRequired,
+    validator.retrieveAttachment, apiV3FormValidator,
     async(req, res) => {
       try {
         const attachmentId = req.params.id;

+ 20 - 18
apps/app/src/server/routes/apiv3/bookmark-folder.ts

@@ -3,6 +3,7 @@ import { body } from 'express-validator';
 import type { Types } from 'mongoose';
 
 import type { BookmarkFolderItems } from '~/interfaces/bookmark-info';
+import { SCOPE } from '~/interfaces/scope';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { InvalidParentBookmarkFolderError } from '~/server/models/errors';
@@ -156,7 +157,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      $ref: '#/components/schemas/BookmarkFolder'
    */
-  router.post('/', accessTokenParser(), loginRequiredStrictly, validator.bookmarkFolder, apiV3FormValidator, async(req, res) => {
+  router.post('/', accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK]), loginRequiredStrictly, validator.bookmarkFolder, apiV3FormValidator, async(req, res) => {
     const owner = req.user?._id;
     const { name, parent } = req.body;
     const params = {
@@ -208,7 +209,7 @@ module.exports = (crowi) => {
    *                        type: object
    *                        $ref: '#/components/schemas/BookmarkFolder'
    */
-  router.get('/list/:userId', accessTokenParser(), loginRequiredStrictly, async(req, res) => {
+  router.get('/list/:userId', accessTokenParser([SCOPE.READ.FEATURES.BOOKMARK]), loginRequiredStrictly, async(req, res) => {
     const { userId } = req.params;
 
     const getBookmarkFolders = async(
@@ -296,7 +297,7 @@ module.exports = (crowi) => {
    *                      description: Number of deleted folders
    *                      example: 1
    */
-  router.delete('/:id', accessTokenParser(), loginRequiredStrictly, async(req, res) => {
+  router.delete('/:id', accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK]), loginRequiredStrictly, async(req, res) => {
     const { id } = req.params;
     try {
       const result = await BookmarkFolder.deleteFolderAndChildren(id);
@@ -352,7 +353,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      $ref: '#/components/schemas/BookmarkFolder'
    */
-  router.put('/', accessTokenParser(), loginRequiredStrictly, validator.bookmarkFolder, async(req, res) => {
+  router.put('/', accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK]), loginRequiredStrictly, validator.bookmarkFolder, async(req, res) => {
     const {
       bookmarkFolderId, name, parent, childFolder,
     } = req.body;
@@ -401,20 +402,21 @@ module.exports = (crowi) => {
    *                      type: object
    *                      $ref: '#/components/schemas/BookmarkFolder'
    */
-  router.post('/add-bookmark-to-folder', accessTokenParser(), loginRequiredStrictly, validator.bookmarkPage, apiV3FormValidator, async(req, res) => {
-    const userId = req.user?._id;
-    const { pageId, folderId } = req.body;
+  router.post('/add-bookmark-to-folder', accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK]), loginRequiredStrictly, validator.bookmarkPage, apiV3FormValidator,
+    async(req, res) => {
+      const userId = req.user?._id;
+      const { pageId, folderId } = req.body;
 
-    try {
-      const bookmarkFolder = await BookmarkFolder.insertOrUpdateBookmarkedPage(pageId, userId, folderId);
-      logger.debug('bookmark added to folder', bookmarkFolder);
-      return res.apiv3({ bookmarkFolder });
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(err, 500);
-    }
-  });
+      try {
+        const bookmarkFolder = await BookmarkFolder.insertOrUpdateBookmarkedPage(pageId, userId, folderId);
+        logger.debug('bookmark added to folder', bookmarkFolder);
+        return res.apiv3({ bookmarkFolder });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err, 500);
+      }
+    });
 
   /**
    * @swagger
@@ -450,7 +452,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      $ref: '#/components/schemas/BookmarkFolder'
    */
-  router.put('/update-bookmark', accessTokenParser(), loginRequiredStrictly, validator.bookmark, async(req, res) => {
+  router.put('/update-bookmark', accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK]), loginRequiredStrictly, validator.bookmark, async(req, res) => {
     const { pageId, status } = req.body;
     const userId = req.user?._id;
     try {

+ 47 - 45
apps/app/src/server/routes/apiv3/bookmarks.js

@@ -1,6 +1,7 @@
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
+import { SCOPE } from '~/interfaces/scope';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { serializeBookmarkSecurely } from '~/server/models/serializers/bookmark-serializer';
@@ -110,7 +111,7 @@ module.exports = (crowi) => {
    *                schema:
    *                  $ref: '#/components/schemas/BookmarkInfo'
    */
-  router.get('/info', accessTokenParser(), loginRequired, validator.bookmarkInfo, apiV3FormValidator, async(req, res) => {
+  router.get('/info', accessTokenParser([SCOPE.READ.FEATURES.BOOKMARK]), loginRequired, validator.bookmarkInfo, apiV3FormValidator, async(req, res) => {
     const { user } = req;
     const { pageId } = req.query;
 
@@ -192,7 +193,7 @@ module.exports = (crowi) => {
     param('userId').isMongoId().withMessage('userId is required'),
   ];
 
-  router.get('/:userId', accessTokenParser(), loginRequired, validator.userBookmarkList, apiV3FormValidator, async(req, res) => {
+  router.get('/:userId', accessTokenParser([SCOPE.READ.FEATURES.BOOKMARK]), loginRequired, validator.userBookmarkList, apiV3FormValidator, async(req, res) => {
     const { userId } = req.params;
 
     if (userId == null) {
@@ -246,62 +247,63 @@ module.exports = (crowi) => {
    *                schema:
    *                  $ref: '#/components/schemas/Bookmark'
    */
-  router.put('/', accessTokenParser(), loginRequiredStrictly, addActivity, validator.bookmarks, apiV3FormValidator, async(req, res) => {
-    const { pageId, bool } = req.body;
-    const userId = req.user?._id;
+  router.put('/', accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK]), loginRequiredStrictly, addActivity, validator.bookmarks, apiV3FormValidator,
+    async(req, res) => {
+      const { pageId, bool } = req.body;
+      const userId = req.user?._id;
 
-    if (userId == null) {
-      return res.apiv3Err('A logged in user is required.');
-    }
-
-    let page;
-    let bookmark;
-    try {
-      page = await Page.findByIdAndViewer(pageId, req.user);
-      if (page == null) {
-        return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
+      if (userId == null) {
+        return res.apiv3Err('A logged in user is required.');
       }
 
-      bookmark = await Bookmark.findByPageIdAndUserId(page._id, req.user._id);
+      let page;
+      let bookmark;
+      try {
+        page = await Page.findByIdAndViewer(pageId, req.user);
+        if (page == null) {
+          return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
+        }
 
-      if (bookmark == null) {
-        if (bool) {
-          bookmark = await Bookmark.add(page, req.user);
+        bookmark = await Bookmark.findByPageIdAndUserId(page._id, req.user._id);
+
+        if (bookmark == null) {
+          if (bool) {
+            bookmark = await Bookmark.add(page, req.user);
+          }
+          else {
+            logger.warn(`Removing the bookmark for ${page._id} by ${req.user._id} failed because the bookmark does not exist.`);
+          }
         }
         else {
-          logger.warn(`Removing the bookmark for ${page._id} by ${req.user._id} failed because the bookmark does not exist.`);
-        }
-      }
-      else {
         // eslint-disable-next-line no-lonely-if
-        if (bool) {
-          logger.warn(`Adding the bookmark for ${page._id} by ${req.user._id} failed because the bookmark has already exist.`);
-        }
-        else {
-          bookmark = await Bookmark.removeBookmark(page, req.user);
+          if (bool) {
+            logger.warn(`Adding the bookmark for ${page._id} by ${req.user._id} failed because the bookmark has already exist.`);
+          }
+          else {
+            bookmark = await Bookmark.removeBookmark(page, req.user);
+          }
         }
       }
-    }
-    catch (err) {
-      logger.error('update-bookmark-failed', err);
-      return res.apiv3Err(err, 500);
-    }
+      catch (err) {
+        logger.error('update-bookmark-failed', err);
+        return res.apiv3Err(err, 500);
+      }
 
-    if (bookmark != null) {
-      bookmark.depopulate('page');
-      bookmark.depopulate('user');
-    }
+      if (bookmark != null) {
+        bookmark.depopulate('page');
+        bookmark.depopulate('user');
+      }
 
-    const parameters = {
-      targetModel: SupportedTargetModel.MODEL_PAGE,
-      target: page,
-      action: bool ? SupportedAction.ACTION_PAGE_BOOKMARK : SupportedAction.ACTION_PAGE_UNBOOKMARK,
-    };
+      const parameters = {
+        targetModel: SupportedTargetModel.MODEL_PAGE,
+        target: page,
+        action: bool ? SupportedAction.ACTION_PAGE_BOOKMARK : SupportedAction.ACTION_PAGE_UNBOOKMARK,
+      };
 
-    activityEvent.emit('update', res.locals.activity._id, parameters, page, preNotifyService.generatePreNotify);
+      activityEvent.emit('update', res.locals.activity._id, parameters, page, preNotifyService.generatePreNotify);
 
-    return res.apiv3({ bookmark });
-  });
+      return res.apiv3({ bookmark });
+    });
 
   return router;
 };

+ 262 - 240
apps/app/src/server/routes/apiv3/customize-setting.js

@@ -8,7 +8,9 @@ import multer from 'multer';
 
 import { GrowiPlugin } from '~/features/growi-plugin/server/models';
 import { SupportedAction } from '~/interfaces/activity';
+import { SCOPE } from '~/interfaces/scope';
 import { AttachmentType } from '~/server/interfaces/attachment';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { Attachment } from '~/server/models/attachment';
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
@@ -274,7 +276,7 @@ module.exports = (crowi) => {
    *                      description: customize params
    *                      $ref: '#/components/schemas/CustomizeSetting'
    */
-  router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, async(req, res) => {
     const customizeParams = {
       isEnabledTimeline: await configManager.getConfig('customize:isEnabledTimeline'),
       isEnabledAttachTitleHeader: await configManager.getConfig('customize:isEnabledAttachTitleHeader'),
@@ -317,7 +319,7 @@ module.exports = (crowi) => {
    *                schema:
    *                  $ref: '#/components/schemas/CustomizeLayout'
    */
-  router.get('/layout', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.get('/layout', accessTokenParser([SCOPE.READ.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, async(req, res) => {
     try {
       const isContainerFluid = await configManager.getConfig('customize:isContainerFluid');
       return res.apiv3({ isContainerFluid });
@@ -357,28 +359,30 @@ module.exports = (crowi) => {
    *                      description: customized params
    *                      $ref: '#/components/schemas/CustomizeLayout'
    */
-  router.put('/layout', loginRequiredStrictly, adminRequired, addActivity, validator.layout, apiV3FormValidator, async(req, res) => {
-    const requestParams = {
-      'customize:isContainerFluid': req.body.isContainerFluid,
-    };
-
-    try {
-      await configManager.updateConfigs(requestParams);
-      const customizedParams = {
-        isContainerFluid: await configManager.getConfig('customize:isContainerFluid'),
+  router.put('/layout', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, addActivity,
+    validator.layout, apiV3FormValidator,
+    async(req, res) => {
+      const requestParams = {
+        'customize:isContainerFluid': req.body.isContainerFluid,
       };
 
-      const parameters = { action: SupportedAction.ACTION_ADMIN_LAYOUT_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
+      try {
+        await configManager.updateConfigs(requestParams);
+        const customizedParams = {
+          isContainerFluid: await configManager.getConfig('customize:isContainerFluid'),
+        };
 
-      return res.apiv3({ customizedParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating layout';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-layout-failed'));
-    }
-  });
+        const parameters = { action: SupportedAction.ACTION_ADMIN_LAYOUT_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3({ customizedParams });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating layout';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-layout-failed'));
+      }
+    });
 
   /**
    * @swagger
@@ -408,7 +412,7 @@ module.exports = (crowi) => {
    *                      items:
    *                        $ref: '#/components/schemas/ThemesMetadata'
    */
-  router.get('/theme', loginRequiredStrictly, async(req, res) => {
+  router.get('/theme', accessTokenParser([SCOPE.READ.ADMIN.CUSTOMIZE]), loginRequiredStrictly, async(req, res) => {
 
     try {
       const currentTheme = await configManager.getConfig('customize:theme');
@@ -457,27 +461,28 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeTheme'
    */
-  router.put('/theme', loginRequiredStrictly, adminRequired, addActivity, validator.theme, apiV3FormValidator, async(req, res) => {
-    const requestParams = {
-      'customize:theme': req.body.theme,
-    };
-
-    try {
-      await configManager.updateConfigs(requestParams);
-      const customizedParams = {
-        theme: await configManager.getConfig('customize:theme'),
+  router.put('/theme', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, addActivity, validator.theme, apiV3FormValidator,
+    async(req, res) => {
+      const requestParams = {
+        'customize:theme': req.body.theme,
       };
-      customizeService.initGrowiTheme();
-      const parameters = { action: SupportedAction.ACTION_ADMIN_THEME_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-      return res.apiv3({ customizedParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating theme';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-theme-failed'));
-    }
-  });
+
+      try {
+        await configManager.updateConfigs(requestParams);
+        const customizedParams = {
+          theme: await configManager.getConfig('customize:theme'),
+        };
+        customizeService.initGrowiTheme();
+        const parameters = { action: SupportedAction.ACTION_ADMIN_THEME_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+        return res.apiv3({ customizedParams });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating theme';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-theme-failed'));
+      }
+    });
 
   /**
    * @swagger
@@ -498,7 +503,7 @@ module.exports = (crowi) => {
    *                schema:
    *                  $ref: '#/components/schemas/CustomizeSidebar'
    */
-  router.get('/sidebar', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.get('/sidebar', accessTokenParser([SCOPE.READ.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, async(req, res) => {
 
     try {
       const isSidebarCollapsedMode = await configManager.getConfig('customize:isSidebarCollapsedMode');
@@ -540,29 +545,31 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeSidebar'
    */
-  router.put('/sidebar', loginRequiredStrictly, adminRequired, validator.sidebar, apiV3FormValidator, addActivity, async(req, res) => {
-    const requestParams = {
-      'customize:isSidebarCollapsedMode': req.body.isSidebarCollapsedMode,
-      'customize:isSidebarClosedAtDockMode': req.body.isSidebarClosedAtDockMode,
-    };
-
-    try {
-      await configManager.updateConfigs(requestParams);
-      const customizedParams = {
-        isSidebarCollapsedMode: await configManager.getConfig('customize:isSidebarCollapsedMode'),
-        isSidebarClosedAtDockMode: await configManager.getConfig('customize:isSidebarClosedAtDockMode'),
+  router.put('/sidebar', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired,
+    validator.sidebar, apiV3FormValidator, addActivity,
+    async(req, res) => {
+      const requestParams = {
+        'customize:isSidebarCollapsedMode': req.body.isSidebarCollapsedMode,
+        'customize:isSidebarClosedAtDockMode': req.body.isSidebarClosedAtDockMode,
       };
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SIDEBAR_UPDATE });
+      try {
+        await configManager.updateConfigs(requestParams);
+        const customizedParams = {
+          isSidebarCollapsedMode: await configManager.getConfig('customize:isSidebarCollapsedMode'),
+          isSidebarClosedAtDockMode: await configManager.getConfig('customize:isSidebarClosedAtDockMode'),
+        };
 
-      return res.apiv3({ customizedParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating sidebar';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-sidebar-failed'));
-    }
-  });
+        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SIDEBAR_UPDATE });
+
+        return res.apiv3({ customizedParams });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating sidebar';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-sidebar-failed'));
+      }
+    });
 
   /**
    * @swagger
@@ -592,44 +599,46 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeFunction'
    */
-  router.put('/function', loginRequiredStrictly, adminRequired, addActivity, validator.function, apiV3FormValidator, async(req, res) => {
-    const requestParams = {
-      'customize:isEnabledTimeline': req.body.isEnabledTimeline,
-      'customize:isEnabledAttachTitleHeader': req.body.isEnabledAttachTitleHeader,
-      'customize:showPageLimitationS': req.body.pageLimitationS,
-      'customize:showPageLimitationM': req.body.pageLimitationM,
-      'customize:showPageLimitationL': req.body.pageLimitationL,
-      'customize:showPageLimitationXL': req.body.pageLimitationXL,
-      'customize:isEnabledStaleNotification': req.body.isEnabledStaleNotification,
-      'customize:isAllReplyShown': req.body.isAllReplyShown,
-      'customize:isSearchScopeChildrenAsDefault': req.body.isSearchScopeChildrenAsDefault,
-      'customize:showPageSideAuthors': req.body.showPageSideAuthors,
-    };
-
-    try {
-      await configManager.updateConfigs(requestParams);
-      const customizedParams = {
-        isEnabledTimeline: await configManager.getConfig('customize:isEnabledTimeline'),
-        isEnabledAttachTitleHeader: await configManager.getConfig('customize:isEnabledAttachTitleHeader'),
-        pageLimitationS: await configManager.getConfig('customize:showPageLimitationS'),
-        pageLimitationM: await configManager.getConfig('customize:showPageLimitationM'),
-        pageLimitationL: await configManager.getConfig('customize:showPageLimitationL'),
-        pageLimitationXL: await configManager.getConfig('customize:showPageLimitationXL'),
-        isEnabledStaleNotification: await configManager.getConfig('customize:isEnabledStaleNotification'),
-        isAllReplyShown: await configManager.getConfig('customize:isAllReplyShown'),
-        isSearchScopeChildrenAsDefault: await configManager.getConfig('customize:isSearchScopeChildrenAsDefault'),
-        showPageSideAuthors: await configManager.getConfig('customize:showPageSideAuthors'),
+  router.put('/function', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, addActivity,
+    validator.function, apiV3FormValidator,
+    async(req, res) => {
+      const requestParams = {
+        'customize:isEnabledTimeline': req.body.isEnabledTimeline,
+        'customize:isEnabledAttachTitleHeader': req.body.isEnabledAttachTitleHeader,
+        'customize:showPageLimitationS': req.body.pageLimitationS,
+        'customize:showPageLimitationM': req.body.pageLimitationM,
+        'customize:showPageLimitationL': req.body.pageLimitationL,
+        'customize:showPageLimitationXL': req.body.pageLimitationXL,
+        'customize:isEnabledStaleNotification': req.body.isEnabledStaleNotification,
+        'customize:isAllReplyShown': req.body.isAllReplyShown,
+        'customize:isSearchScopeChildrenAsDefault': req.body.isSearchScopeChildrenAsDefault,
+        'customize:showPageSideAuthors': req.body.showPageSideAuthors,
       };
-      const parameters = { action: SupportedAction.ACTION_ADMIN_FUNCTION_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-      return res.apiv3({ customizedParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating function';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-function-failed'));
-    }
-  });
+
+      try {
+        await configManager.updateConfigs(requestParams);
+        const customizedParams = {
+          isEnabledTimeline: await configManager.getConfig('customize:isEnabledTimeline'),
+          isEnabledAttachTitleHeader: await configManager.getConfig('customize:isEnabledAttachTitleHeader'),
+          pageLimitationS: await configManager.getConfig('customize:showPageLimitationS'),
+          pageLimitationM: await configManager.getConfig('customize:showPageLimitationM'),
+          pageLimitationL: await configManager.getConfig('customize:showPageLimitationL'),
+          pageLimitationXL: await configManager.getConfig('customize:showPageLimitationXL'),
+          isEnabledStaleNotification: await configManager.getConfig('customize:isEnabledStaleNotification'),
+          isAllReplyShown: await configManager.getConfig('customize:isAllReplyShown'),
+          isSearchScopeChildrenAsDefault: await configManager.getConfig('customize:isSearchScopeChildrenAsDefault'),
+          showPageSideAuthors: await configManager.getConfig('customize:showPageSideAuthors'),
+        };
+        const parameters = { action: SupportedAction.ACTION_ADMIN_FUNCTION_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+        return res.apiv3({ customizedParams });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating function';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-function-failed'));
+      }
+    });
 
 
   /**
@@ -660,26 +669,28 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizePresentation'
    */
-  router.put('/presentation', loginRequiredStrictly, adminRequired, addActivity, validator.CustomizePresentation, apiV3FormValidator, async(req, res) => {
-    const requestParams = {
-      'customize:isEnabledMarp': req.body.isEnabledMarp,
-    };
-
-    try {
-      await configManager.updateConfigs(requestParams);
-      const customizedParams = {
-        isEnabledMarp: await configManager.getConfig('customize:isEnabledMarp'),
+  router.put('/presentation', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, addActivity,
+    validator.CustomizePresentation, apiV3FormValidator,
+    async(req, res) => {
+      const requestParams = {
+        'customize:isEnabledMarp': req.body.isEnabledMarp,
       };
-      const parameters = { action: SupportedAction.ACTION_ADMIN_FUNCTION_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-      return res.apiv3({ customizedParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating presentaion';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-presentation-failed'));
-    }
-  });
+
+      try {
+        await configManager.updateConfigs(requestParams);
+        const customizedParams = {
+          isEnabledMarp: await configManager.getConfig('customize:isEnabledMarp'),
+        };
+        const parameters = { action: SupportedAction.ACTION_ADMIN_FUNCTION_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+        return res.apiv3({ customizedParams });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating presentaion';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-presentation-failed'));
+      }
+    });
 
   /**
    * @swagger
@@ -709,28 +720,30 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeHighlightResponse'
    */
-  router.put('/highlight', loginRequiredStrictly, adminRequired, addActivity, validator.highlight, apiV3FormValidator, async(req, res) => {
-    const requestParams = {
-      'customize:highlightJsStyle': req.body.highlightJsStyle,
-      'customize:highlightJsStyleBorder': req.body.highlightJsStyleBorder,
-    };
-
-    try {
-      await configManager.updateConfigs(requestParams);
-      const customizedParams = {
-        styleName: await configManager.getConfig('customize:highlightJsStyle'),
-        styleBorder: await configManager.getConfig('customize:highlightJsStyleBorder'),
+  router.put('/highlight', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, addActivity,
+    validator.highlight, apiV3FormValidator,
+    async(req, res) => {
+      const requestParams = {
+        'customize:highlightJsStyle': req.body.highlightJsStyle,
+        'customize:highlightJsStyleBorder': req.body.highlightJsStyleBorder,
       };
-      const parameters = { action: SupportedAction.ACTION_ADMIN_CODE_HIGHLIGHT_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-      return res.apiv3({ customizedParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating highlight';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-highlight-failed'));
-    }
-  });
+
+      try {
+        await configManager.updateConfigs(requestParams);
+        const customizedParams = {
+          styleName: await configManager.getConfig('customize:highlightJsStyle'),
+          styleBorder: await configManager.getConfig('customize:highlightJsStyleBorder'),
+        };
+        const parameters = { action: SupportedAction.ACTION_ADMIN_CODE_HIGHLIGHT_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+        return res.apiv3({ customizedParams });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating highlight';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-highlight-failed'));
+      }
+    });
 
   /**
    * @swagger
@@ -760,29 +773,31 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeTitle'
    */
-  router.put('/customize-title', loginRequiredStrictly, adminRequired, addActivity, validator.customizeTitle, apiV3FormValidator, async(req, res) => {
-    const requestParams = {
-      'customize:title': req.body.customizeTitle,
-    };
+  router.put('/customize-title', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, addActivity,
+    validator.customizeTitle, apiV3FormValidator,
+    async(req, res) => {
+      const requestParams = {
+        'customize:title': req.body.customizeTitle,
+      };
 
-    try {
-      await configManager.updateConfigs(requestParams, { skipPubsub: true });
-      crowi.customizeService.publishUpdatedMessage();
+      try {
+        await configManager.updateConfigs(requestParams, { skipPubsub: true });
+        crowi.customizeService.publishUpdatedMessage();
 
-      const customizedParams = {
-        customizeTitle: await configManager.getConfig('customize:title'),
-      };
-      customizeService.initCustomTitle();
-      const parameters = { action: SupportedAction.ACTION_ADMIN_CUSTOM_TITLE_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-      return res.apiv3({ customizedParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating customizeTitle';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-customizeTitle-failed'));
-    }
-  });
+        const customizedParams = {
+          customizeTitle: await configManager.getConfig('customize:title'),
+        };
+        customizeService.initCustomTitle();
+        const parameters = { action: SupportedAction.ACTION_ADMIN_CUSTOM_TITLE_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+        return res.apiv3({ customizedParams });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating customizeTitle';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-customizeTitle-failed'));
+      }
+    });
 
   /**
    * @swagger
@@ -812,25 +827,27 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeNoscript'
    */
-  router.put('/customize-noscript', loginRequiredStrictly, adminRequired, addActivity, validator.customizeNoscript, apiV3FormValidator, async(req, res) => {
-    const requestParams = {
-      'customize:noscript': req.body.customizeNoscript,
-    };
-    try {
-      await configManager.updateConfigs(requestParams);
-      const customizedParams = {
-        customizeNoscript: await configManager.getConfig('customize:noscript'),
+  router.put('/customize-noscript', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, addActivity,
+    validator.customizeNoscript, apiV3FormValidator,
+    async(req, res) => {
+      const requestParams = {
+        'customize:noscript': req.body.customizeNoscript,
       };
-      const parameters = { action: SupportedAction.ACTION_ADMIN_CUSTOM_NOSCRIPT_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-      return res.apiv3({ customizedParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating customizeNoscript';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-customizeNoscript-failed'));
-    }
-  });
+      try {
+        await configManager.updateConfigs(requestParams);
+        const customizedParams = {
+          customizeNoscript: await configManager.getConfig('customize:noscript'),
+        };
+        const parameters = { action: SupportedAction.ACTION_ADMIN_CUSTOM_NOSCRIPT_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+        return res.apiv3({ customizedParams });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating customizeNoscript';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-customizeNoscript-failed'));
+      }
+    });
 
   /**
    * @swagger
@@ -860,28 +877,30 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeCss'
    */
-  router.put('/customize-css', loginRequiredStrictly, adminRequired, addActivity, validator.customizeCss, apiV3FormValidator, async(req, res) => {
-    const requestParams = {
-      'customize:css': req.body.customizeCss,
-    };
-    try {
-      await configManager.updateConfigs(requestParams, { skipPubsub: true });
-      crowi.customizeService.publishUpdatedMessage();
-
-      const customizedParams = {
-        customizeCss: await configManager.getConfig('customize:css'),
+  router.put('/customize-css', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, addActivity,
+    validator.customizeCss, apiV3FormValidator,
+    async(req, res) => {
+      const requestParams = {
+        'customize:css': req.body.customizeCss,
       };
-      customizeService.initCustomCss();
-      const parameters = { action: SupportedAction.ACTION_ADMIN_CUSTOM_CSS_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-      return res.apiv3({ customizedParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating customizeCss';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-customizeCss-failed'));
-    }
-  });
+      try {
+        await configManager.updateConfigs(requestParams, { skipPubsub: true });
+        crowi.customizeService.publishUpdatedMessage();
+
+        const customizedParams = {
+          customizeCss: await configManager.getConfig('customize:css'),
+        };
+        customizeService.initCustomCss();
+        const parameters = { action: SupportedAction.ACTION_ADMIN_CUSTOM_CSS_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+        return res.apiv3({ customizedParams });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating customizeCss';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-customizeCss-failed'));
+      }
+    });
 
   /**
    * @swagger
@@ -911,25 +930,27 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeScript'
    */
-  router.put('/customize-script', loginRequiredStrictly, adminRequired, addActivity, validator.customizeScript, apiV3FormValidator, async(req, res) => {
-    const requestParams = {
-      'customize:script': req.body.customizeScript,
-    };
-    try {
-      await configManager.updateConfigs(requestParams);
-      const customizedParams = {
-        customizeScript: await configManager.getConfig('customize:script'),
+  router.put('/customize-script', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, addActivity,
+    validator.customizeScript, apiV3FormValidator,
+    async(req, res) => {
+      const requestParams = {
+        'customize:script': req.body.customizeScript,
       };
-      const parameters = { action: SupportedAction.ACTION_ADMIN_CUSTOM_SCRIPT_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-      return res.apiv3({ customizedParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating customizeScript';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-customizeScript-failed'));
-    }
-  });
+      try {
+        await configManager.updateConfigs(requestParams);
+        const customizedParams = {
+          customizeScript: await configManager.getConfig('customize:script'),
+        };
+        const parameters = { action: SupportedAction.ACTION_ADMIN_CUSTOM_SCRIPT_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+        return res.apiv3({ customizedParams });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating customizeScript';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-customizeScript-failed'));
+      }
+    });
 
   /**
    * @swagger
@@ -959,28 +980,29 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeLogo'
    */
-  router.put('/customize-logo', loginRequiredStrictly, adminRequired, validator.logo, apiV3FormValidator, async(req, res) => {
+  router.put('/customize-logo', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired,
+    validator.logo, apiV3FormValidator,
+    async(req, res) => {
+      const {
+        isDefaultLogo,
+      } = req.body;
 
-    const {
-      isDefaultLogo,
-    } = req.body;
-
-    const requestParams = {
-      'customize:isDefaultLogo': isDefaultLogo,
-    };
-    try {
-      await configManager.updateConfigs(requestParams);
-      const customizedParams = {
-        isDefaultLogo: await configManager.getConfig('customize:isDefaultLogo'),
+      const requestParams = {
+        'customize:isDefaultLogo': isDefaultLogo,
       };
-      return res.apiv3({ customizedParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating customizeLogo';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-customizeLogo-failed'));
-    }
-  });
+      try {
+        await configManager.updateConfigs(requestParams);
+        const customizedParams = {
+          isDefaultLogo: await configManager.getConfig('customize:isDefaultLogo'),
+        };
+        return res.apiv3({ customizedParams });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating customizeLogo';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-customizeLogo-failed'));
+      }
+    });
 
   /**
    * @swagger
@@ -1021,7 +1043,7 @@ module.exports = (crowi) => {
    *                            temporaryUrlExpiredAt: {}
    *                            temporaryUrlCached: {}
    */
-  router.post('/upload-brand-logo', uploads.single('file'), loginRequiredStrictly,
+  router.post('/upload-brand-logo', uploads.single('file'), accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly,
     adminRequired, validator.logo, apiV3FormValidator, async(req, res) => {
 
       if (req.file == null) {
@@ -1077,7 +1099,7 @@ module.exports = (crowi) => {
    *                schema:
    *                  additionalProperties: false
    */
-  router.delete('/delete-brand-logo', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.delete('/delete-brand-logo', accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, adminRequired, async(req, res) => {
 
     const attachments = await Attachment.find({ attachmentType: AttachmentType.BRAND_LOGO });
 

+ 21 - 18
apps/app/src/server/routes/apiv3/export.js

@@ -1,6 +1,7 @@
 import { SupportedAction } from '~/interfaces/activity';
-import { exportService } from '~/server/service/export';
+import { SCOPE } from '~/interfaces/scope';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import { exportService } from '~/server/service/export';
 import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
@@ -169,7 +170,7 @@ module.exports = (crowi) => {
    *                  status:
    *                    $ref: '#/components/schemas/ExportStatus'
    */
-  router.get('/status', accessTokenParser(), loginRequired, adminRequired, async(req, res) => {
+  router.get('/status', accessTokenParser([SCOPE.READ.ADMIN.EXPORET_DATA]), loginRequired, adminRequired, async(req, res) => {
     const status = await exportService.getStatus();
 
     // TODO: use res.apiv3
@@ -210,7 +211,7 @@ module.exports = (crowi) => {
    *                    type: boolean
    *                    description: whether the request is succeeded
    */
-  router.post('/', accessTokenParser(), loginRequired, adminRequired, addActivity, async(req, res) => {
+  router.post('/', accessTokenParser([SCOPE.WRITE.ADMIN.EXPORET_DATA]), loginRequired, adminRequired, addActivity, async(req, res) => {
     // TODO: add express validator
     try {
       const { collections } = req.body;
@@ -260,25 +261,27 @@ module.exports = (crowi) => {
    *                    type: boolean
    *                    description: whether the request is succeeded
    */
-  router.delete('/:fileName', accessTokenParser(), loginRequired, adminRequired, validator.deleteFile, apiV3FormValidator, addActivity, async(req, res) => {
+  router.delete('/:fileName', accessTokenParser([SCOPE.WRITE.ADMIN.EXPORET_DATA]), loginRequired, adminRequired,
+    validator.deleteFile, apiV3FormValidator, addActivity,
+    async(req, res) => {
     // TODO: add express validator
-    const { fileName } = req.params;
+      const { fileName } = req.params;
 
-    try {
-      const zipFile = exportService.getFile(fileName);
-      fs.unlinkSync(zipFile);
-      const parameters = { action: SupportedAction.ACTION_ADMIN_ARCHIVE_DATA_DELETE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
+      try {
+        const zipFile = exportService.getFile(fileName);
+        fs.unlinkSync(zipFile);
+        const parameters = { action: SupportedAction.ACTION_ADMIN_ARCHIVE_DATA_DELETE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
 
-      // TODO: use res.apiv3
-      return res.status(200).send({ ok: true });
-    }
-    catch (err) {
+        // TODO: use res.apiv3
+        return res.status(200).send({ ok: true });
+      }
+      catch (err) {
       // TODO: use ApiV3Error
-      logger.error(err);
-      return res.status(500).send({ ok: false });
-    }
-  });
+        logger.error(err);
+        return res.status(500).send({ ok: false });
+      }
+    });
 
   return router;
 };

+ 3 - 2
apps/app/src/server/routes/apiv3/g2g-transfer.ts

@@ -7,6 +7,7 @@ import express from 'express';
 import { body } from 'express-validator';
 import multer from 'multer';
 
+import { SCOPE } from '~/interfaces/scope';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { isG2GTransferError } from '~/server/models/vo/g2g-transfer-error';
 import { configManager } from '~/server/service/config-manager';
@@ -270,7 +271,7 @@ module.exports = (crowi: Crowi): Router => {
   });
 
   // eslint-disable-next-line max-len
-  receiveRouter.post('/generate-key', accessTokenParser(), adminRequiredIfInstalled, appSiteUrlRequiredIfNotInstalled, async(req: Request, res: ApiV3Response) => {
+  receiveRouter.post('/generate-key', accessTokenParser([SCOPE.WRITE.ADMIN.EXPORET_DATA]), adminRequiredIfInstalled, appSiteUrlRequiredIfNotInstalled, async(req: Request, res: ApiV3Response) => {
     const appSiteUrl = req.body.appSiteUrl ?? configManager.getConfig('app:siteUrl');
 
     let appSiteUrlOrigin: string;
@@ -296,7 +297,7 @@ module.exports = (crowi: Crowi): Router => {
   });
 
   // eslint-disable-next-line max-len
-  pushRouter.post('/transfer', accessTokenParser(), loginRequiredStrictly, adminRequired, validator.transfer, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
+  pushRouter.post('/transfer', accessTokenParser([SCOPE.WRITE.ADMIN.EXPORET_DATA]), loginRequiredStrictly, adminRequired, validator.transfer, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const { transferKey, collections, optionsMap } = req.body;
 
     // Parse transfer key

+ 30 - 28
apps/app/src/server/routes/apiv3/import.js

@@ -1,6 +1,7 @@
 import { ErrorV3 } from '@growi/core/dist/models';
 
 import { SupportedAction } from '~/interfaces/activity';
+import { SCOPE } from '~/interfaces/scope';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { getImportService } from '~/server/service/import';
 import { generateOverwriteParams } from '~/server/service/import/overwrite-params';
@@ -115,7 +116,7 @@ export default function route(crowi) {
    *                    type: object
    *                    description: import settings params
    */
-  router.get('/', accessTokenParser(), loginRequired, adminRequired, async(req, res) => {
+  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.IMPORT_DATA]), loginRequired, adminRequired, async(req, res) => {
     try {
       const importSettingsParams = {
         esaTeamName: await crowi.configManager.getConfig('importer:esa:team_name'),
@@ -151,7 +152,7 @@ export default function route(crowi) {
    *                  status:
    *                    $ref: '#/components/schemas/ImportStatus'
    */
-  router.get('/status', accessTokenParser(), loginRequired, adminRequired, async(req, res) => {
+  router.get('/status', accessTokenParser([SCOPE.READ.ADMIN.IMPORT_DATA]), loginRequired, adminRequired, async(req, res) => {
     try {
       const status = await importService.getStatus();
       return res.apiv3(status);
@@ -196,7 +197,7 @@ export default function route(crowi) {
    *        200:
    *          description: Import process has requested
    */
-  router.post('/', accessTokenParser(), loginRequired, adminRequired, addActivity, async(req, res) => {
+  router.post('/', accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA]), loginRequired, adminRequired, addActivity, async(req, res) => {
     // TODO: add express validator
     const { fileName, collections, options } = req.body;
 
@@ -319,34 +320,35 @@ export default function route(crowi) {
    *                      type: object
    *                      description: the property of each extracted file
    */
-  router.post('/upload', accessTokenParser(), loginRequired, adminRequired, uploads.single('file'), addActivity, async(req, res) => {
-    const { file } = req;
-    const zipFile = importService.getFile(file.filename);
-    let data = null;
-
-    try {
-      data = await growiBridgeService.parseZipFile(zipFile);
-    }
-    catch (err) {
+  router.post('/upload', accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA]), loginRequired, adminRequired, uploads.single('file'), addActivity,
+    async(req, res) => {
+      const { file } = req;
+      const zipFile = importService.getFile(file.filename);
+      let data = null;
+
+      try {
+        data = await growiBridgeService.parseZipFile(zipFile);
+      }
+      catch (err) {
       // TODO: use ApiV3Error
-      logger.error(err);
-      return res.status(500).send({ status: 'ERROR' });
-    }
-    try {
+        logger.error(err);
+        return res.status(500).send({ status: 'ERROR' });
+      }
+      try {
       // validate with meta.json
-      importService.validate(data.meta);
+        importService.validate(data.meta);
 
-      const parameters = { action: SupportedAction.ACTION_ADMIN_ARCHIVE_DATA_UPLOAD };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
+        const parameters = { action: SupportedAction.ACTION_ADMIN_ARCHIVE_DATA_UPLOAD };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
 
-      return res.apiv3(data);
-    }
-    catch {
-      const msg = 'The version of this GROWI and the uploaded GROWI data are not the same';
-      const validationErr = 'versions-are-not-met';
-      return res.apiv3Err(new ErrorV3(msg, validationErr), 500);
-    }
-  });
+        return res.apiv3(data);
+      }
+      catch {
+        const msg = 'The version of this GROWI and the uploaded GROWI data are not the same';
+        const validationErr = 'versions-are-not-met';
+        return res.apiv3Err(new ErrorV3(msg, validationErr), 500);
+      }
+    });
 
   /**
    * @swagger
@@ -361,7 +363,7 @@ export default function route(crowi) {
    *        200:
    *          description: all files are deleted
    */
-  router.delete('/all', accessTokenParser(), loginRequired, adminRequired, async(req, res) => {
+  router.delete('/all', accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA]), loginRequired, adminRequired, async(req, res) => {
     try {
       importService.deleteAllZipFiles();
 

+ 80 - 75
apps/app/src/server/routes/apiv3/in-app-notification.ts

@@ -3,6 +3,7 @@ import express from 'express';
 
 import { SupportedAction } from '~/interfaces/activity';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
+import { SCOPE } from '~/interfaces/scope';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 
@@ -24,108 +25,112 @@ module.exports = (crowi) => {
 
   const activityEvent = crowi.event('activity');
 
-  router.get('/list', accessTokenParser(), loginRequiredStrictly, async(req: CrowiRequest, res: ApiV3Response) => {
+  router.get('/list', accessTokenParser([SCOPE.READ.USER_SETTINGS.IN_APP_NOTIFICATION]), loginRequiredStrictly,
+    async(req: CrowiRequest, res: ApiV3Response) => {
     // user must be set by loginRequiredStrictly
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    const user = req.user!;
+      const user = req.user!;
 
-    const limit = req.query.limit != null
-      ? parseInt(req.query.limit.toString()) || 10
-      : 10;
+      const limit = req.query.limit != null
+        ? parseInt(req.query.limit.toString()) || 10
+        : 10;
 
-    let offset = 0;
-    if (req.query.offset != null) {
-      offset = parseInt(req.query.offset.toString(), 10);
-    }
+      let offset = 0;
+      if (req.query.offset != null) {
+        offset = parseInt(req.query.offset.toString(), 10);
+      }
 
-    const queryOptions = {
-      offset,
-      limit,
-    };
+      const queryOptions = {
+        offset,
+        limit,
+      };
 
-    // set in-app-notification status to categorize
-    if (req.query.status != null) {
-      Object.assign(queryOptions, { status: req.query.status });
-    }
+      // set in-app-notification status to categorize
+      if (req.query.status != null) {
+        Object.assign(queryOptions, { status: req.query.status });
+      }
 
-    const paginationResult = await inAppNotificationService.getLatestNotificationsByUser(user._id, queryOptions);
+      const paginationResult = await inAppNotificationService.getLatestNotificationsByUser(user._id, queryOptions);
 
 
-    const getActionUsersFromActivities = function(activities) {
-      return activities.map(({ user }) => user).filter((user, i, self) => self.indexOf(user) === i);
-    };
+      const getActionUsersFromActivities = function(activities) {
+        return activities.map(({ user }) => user).filter((user, i, self) => self.indexOf(user) === i);
+      };
 
-    const serializedDocs: Array<IInAppNotification> = paginationResult.docs.map((doc) => {
-      if (doc.user != null && doc.user instanceof User) {
-        doc.user = serializeUserSecurely(doc.user);
-      }
-      // To add a new property into mongoose doc, need to change the format of doc to an object
-      const docObj: IInAppNotification = doc.toObject();
-      const actionUsersNew = getActionUsersFromActivities(doc.activities);
+      const serializedDocs: Array<IInAppNotification> = paginationResult.docs.map((doc) => {
+        if (doc.user != null && doc.user instanceof User) {
+          doc.user = serializeUserSecurely(doc.user);
+        }
+        // To add a new property into mongoose doc, need to change the format of doc to an object
+        const docObj: IInAppNotification = doc.toObject();
+        const actionUsersNew = getActionUsersFromActivities(doc.activities);
 
-      const serializedActionUsers = actionUsersNew.map((actionUser) => {
-        return serializeUserSecurely(actionUser);
-      });
+        const serializedActionUsers = actionUsersNew.map((actionUser) => {
+          return serializeUserSecurely(actionUser);
+        });
 
-      docObj.actionUsers = serializedActionUsers;
-      return docObj;
-    });
+        docObj.actionUsers = serializedActionUsers;
+        return docObj;
+      });
 
-    const serializedPaginationResult = {
-      ...paginationResult,
-      docs: serializedDocs,
-    };
+      const serializedPaginationResult = {
+        ...paginationResult,
+        docs: serializedDocs,
+      };
 
-    return res.apiv3(serializedPaginationResult);
-  });
+      return res.apiv3(serializedPaginationResult);
+    });
 
-  router.get('/status', accessTokenParser(), loginRequiredStrictly, async(req: CrowiRequest, res: ApiV3Response) => {
+  router.get('/status', accessTokenParser([SCOPE.READ.USER_SETTINGS.IN_APP_NOTIFICATION]), loginRequiredStrictly,
+    async(req: CrowiRequest, res: ApiV3Response) => {
     // user must be set by loginRequiredStrictly
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    const user = req.user!;
-
-    try {
-      const count = await inAppNotificationService.getUnreadCountByUser(user._id);
-      return res.apiv3({ count });
-    }
-    catch (err) {
-      return res.apiv3Err(err);
-    }
-  });
-
-  router.post('/open', accessTokenParser(), loginRequiredStrictly, async(req: CrowiRequest, res: ApiV3Response) => {
+      const user = req.user!;
+
+      try {
+        const count = await inAppNotificationService.getUnreadCountByUser(user._id);
+        return res.apiv3({ count });
+      }
+      catch (err) {
+        return res.apiv3Err(err);
+      }
+    });
+
+  router.post('/open', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.IN_APP_NOTIFICATION]), loginRequiredStrictly,
+    async(req: CrowiRequest, res: ApiV3Response) => {
     // user must be set by loginRequiredStrictly
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    const user = req.user!;
+      const user = req.user!;
 
-    const id = req.body.id;
+      const id = req.body.id;
 
-    try {
-      const notification = await inAppNotificationService.open(user, id);
-      const result = { notification };
-      return res.apiv3(result);
-    }
-    catch (err) {
-      return res.apiv3Err(err);
-    }
-  });
+      try {
+        const notification = await inAppNotificationService.open(user, id);
+        const result = { notification };
+        return res.apiv3(result);
+      }
+      catch (err) {
+        return res.apiv3Err(err);
+      }
+    });
 
-  router.put('/all-statuses-open', accessTokenParser(), loginRequiredStrictly, addActivity, async(req: CrowiRequest, res: ApiV3Response) => {
+  router.put('/all-statuses-open', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.IN_APP_NOTIFICATION]), loginRequiredStrictly, addActivity,
+    async(req: CrowiRequest, res: ApiV3Response) => {
     // user must be set by loginRequiredStrictly
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    const user = req.user!;
+      const user = req.user!;
 
-    try {
-      await inAppNotificationService.updateAllNotificationsAsOpened(user);
+      try {
+        await inAppNotificationService.updateAllNotificationsAsOpened(user);
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_IN_APP_NOTIFICATION_ALL_STATUSES_OPEN });
+        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_IN_APP_NOTIFICATION_ALL_STATUSES_OPEN });
 
-      return res.apiv3();
-    }
-    catch (err) {
-      return res.apiv3Err(err);
-    }
-  });
+        return res.apiv3();
+      }
+      catch (err) {
+        return res.apiv3Err(err);
+      }
+    });
 
   return router;
 };

+ 211 - 172
apps/app/src/server/routes/apiv3/notification-setting.js

@@ -1,6 +1,9 @@
 import { ErrorV3 } from '@growi/core/dist/models';
+import express from 'express';
 
 import { SupportedAction } from '~/interfaces/activity';
+import { SCOPE } from '~/interfaces/scope';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { GlobalNotificationSettingType } from '~/server/models/GlobalNotificationSetting';
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
@@ -14,7 +17,6 @@ import UpdatePost from '../../models/update-post';
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:notification-setting');
 
-const express = require('express');
 
 const router = express.Router();
 
@@ -174,7 +176,7 @@ const validator = {
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const Strictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
@@ -206,7 +208,7 @@ module.exports = (crowi) => {
    *                      description: notification params
    *                      $ref: '#/components/schemas/NotificationParams'
    */
-  router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.EXTERNAL_NOTIFICATION]), Strictly, adminRequired, async(req, res) => {
 
     const notificationParams = {
       // status of slack intagration
@@ -258,7 +260,7 @@ module.exports = (crowi) => {
   *                            description: user notification settings
   */
   // eslint-disable-next-line max-len
-  router.post('/user-notification', loginRequiredStrictly, adminRequired, addActivity, validator.userNotification, apiV3FormValidator, async(req, res) => {
+  router.post('/user-notification', accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]), Strictly, adminRequired, addActivity, validator.userNotification, apiV3FormValidator, async(req, res) => {
     const { pathPattern, channel } = req.body;
 
     try {
@@ -305,25 +307,28 @@ module.exports = (crowi) => {
    *                schema:
    *                  $ref: '#/components/schemas/UserNotification'
    */
-  router.delete('/user-notification/:id', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
-    const { id } = req.params;
+  router.delete('/user-notification/:id',
+    accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
+    Strictly,
+    adminRequired,
+    addActivity,
+    async(req, res) => {
+      const { id } = req.params;
 
-    try {
-      const deletedNotificaton = await UpdatePost.findOneAndRemove({ _id: id });
-
-      const parameters = { action: SupportedAction.ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_DELETE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-
-      return res.apiv3(deletedNotificaton);
-    }
-    catch (err) {
-      const msg = 'Error occurred in delete user trigger notification';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'delete-userTriggerNotification-failed'));
-    }
+      try {
+        const deletedNotificaton = await UpdatePost.findOneAndRemove({ _id: id });
 
+        const parameters = { action: SupportedAction.ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_DELETE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
 
-  });
+        return res.apiv3(deletedNotificaton);
+      }
+      catch (err) {
+        const msg = 'Error occurred in delete user trigger notification';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'delete-userTriggerNotification-failed'));
+      }
+    });
 
 
   /**
@@ -352,22 +357,27 @@ module.exports = (crowi) => {
    *                    globalNotification:
    *                      $ref: '#/components/schemas/GlobalNotification'
    */
-  router.get('/global-notification/:id', loginRequiredStrictly, adminRequired, validator.globalNotification, async(req, res) => {
-
-    const notificationSettingId = req.params.id;
-    let globalNotification;
-
-    if (notificationSettingId) {
-      try {
-        globalNotification = await GlobalNotificationSetting.findOne({ _id: notificationSettingId });
+  router.get('/global-notification/:id',
+    accessTokenParser([SCOPE.READ.ADMIN.EXTERNAL_NOTIFICATION]),
+    Strictly,
+    adminRequired,
+    validator.globalNotification,
+    async(req, res) => {
+
+      const notificationSettingId = req.params.id;
+      let globalNotification;
+
+      if (notificationSettingId) {
+        try {
+          globalNotification = await GlobalNotificationSetting.findOne({ _id: notificationSettingId });
+        }
+        catch (err) {
+          logger.error(`Error in finding a global notification setting with {_id: ${notificationSettingId}}`);
+        }
       }
-      catch (err) {
-        logger.error(`Error in finding a global notification setting with {_id: ${notificationSettingId}}`);
-      }
-    }
 
-    return res.apiv3({ globalNotification });
-  });
+      return res.apiv3({ globalNotification });
+    });
 
   /**
    * @swagger
@@ -397,41 +407,46 @@ module.exports = (crowi) => {
    *                      $ref: '#/components/schemas/GlobalNotification'
    */
   // eslint-disable-next-line max-len
-  router.post('/global-notification', loginRequiredStrictly, adminRequired, addActivity, validator.globalNotification, apiV3FormValidator, async(req, res) => {
-
-    const {
-      notifyType, toEmail, slackChannels, triggerPath, triggerEvents,
-    } = req.body;
-
-    let notification;
-
-    if (notifyType === GlobalNotificationSettingType.MAIL) {
-      notification = new GlobalNotificationMailSetting(crowi);
-      notification.toEmail = toEmail;
-    }
-    if (notifyType === GlobalNotificationSettingType.SLACK) {
-      notification = new GlobalNotificationSlackSetting(crowi);
-      notification.slackChannels = slackChannels;
-    }
+  router.post('/global-notification',
+    accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
+    Strictly,
+    adminRequired,
+    addActivity,
+    validator.globalNotification,
+    apiV3FormValidator,
+    async(req, res) => {
+      const {
+        notifyType, toEmail, slackChannels, triggerPath, triggerEvents,
+      } = req.body;
+
+      let notification;
 
-    notification.triggerPath = triggerPath;
-    notification.triggerEvents = triggerEvents || [];
+      if (notifyType === GlobalNotificationSettingType.MAIL) {
+        notification = new GlobalNotificationMailSetting(crowi);
+        notification.toEmail = toEmail;
+      }
+      if (notifyType === GlobalNotificationSettingType.SLACK) {
+        notification = new GlobalNotificationSlackSetting(crowi);
+        notification.slackChannels = slackChannels;
+      }
 
-    try {
-      const createdNotification = await notification.save();
+      notification.triggerPath = triggerPath;
+      notification.triggerEvents = triggerEvents || [];
 
-      const parameters = { action: SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
+      try {
+        const createdNotification = await notification.save();
 
-      return res.apiv3({ createdNotification }, 201);
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating global notification';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'post-globalNotification-failed'));
-    }
+        const parameters = { action: SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
 
-  });
+        return res.apiv3({ createdNotification }, 201);
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating global notification';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'post-globalNotification-failed'));
+      }
+    });
 
   /**
    * @swagger
@@ -466,58 +481,65 @@ module.exports = (crowi) => {
    *                      $ref: '#/components/schemas/GlobalNotification'
    */
   // eslint-disable-next-line max-len
-  router.put('/global-notification/:id', loginRequiredStrictly, adminRequired, addActivity, validator.globalNotification, apiV3FormValidator, async(req, res) => {
-    const { id } = req.params;
-    const {
-      notifyType, toEmail, slackChannels, triggerPath, triggerEvents,
-    } = req.body;
-
-    const models = {
-      [GlobalNotificationSettingType.MAIL]: GlobalNotificationMailSetting,
-      [GlobalNotificationSettingType.SLACK]: GlobalNotificationSlackSetting,
-    };
+  router.put('/global-notification/:id',
+    accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
+    Strictly,
+    adminRequired,
+    addActivity,
+    validator.globalNotification,
+    apiV3FormValidator,
+    async(req, res) => {
+      const { id } = req.params;
+      const {
+        notifyType, toEmail, slackChannels, triggerPath, triggerEvents,
+      } = req.body;
+
+      const models = {
+        [GlobalNotificationSettingType.MAIL]: GlobalNotificationMailSetting,
+        [GlobalNotificationSettingType.SLACK]: GlobalNotificationSlackSetting,
+      };
 
-    try {
-      let setting = await GlobalNotificationSetting.findOne({ _id: id });
-      setting = setting.toObject();
-
-      // when switching from one type to another,
-      // remove toEmail from slack setting and slackChannels from mail setting
-      if (setting.__t !== notifyType) {
-        setting = models[setting.__t].hydrate(setting);
-        setting.toEmail = undefined;
-        setting.slackChannels = undefined;
-        await setting.save();
+      try {
+        let setting = await GlobalNotificationSetting.findOne({ _id: id });
         setting = setting.toObject();
-      }
 
-      if (notifyType === GlobalNotificationSettingType.MAIL) {
-        setting = GlobalNotificationMailSetting.hydrate(setting);
-        setting.toEmail = toEmail;
+        // when switching from one type to another,
+        // remove toEmail from slack setting and slackChannels from mail setting
+        if (setting.__t !== notifyType) {
+          setting = models[setting.__t].hydrate(setting);
+          setting.toEmail = undefined;
+          setting.slackChannels = undefined;
+          await setting.save();
+          setting = setting.toObject();
+        }
+
+        if (notifyType === GlobalNotificationSettingType.MAIL) {
+          setting = GlobalNotificationMailSetting.hydrate(setting);
+          setting.toEmail = toEmail;
+        }
+        if (notifyType === GlobalNotificationSettingType.SLACK) {
+          setting = GlobalNotificationSlackSetting.hydrate(setting);
+          setting.slackChannels = slackChannels;
+        }
+
+        setting.__t = notifyType;
+        setting.triggerPath = triggerPath;
+        setting.triggerEvents = triggerEvents || [];
+
+        const createdNotification = await setting.save();
+
+        const parameters = { action: SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3({ createdNotification });
       }
-      if (notifyType === GlobalNotificationSettingType.SLACK) {
-        setting = GlobalNotificationSlackSetting.hydrate(setting);
-        setting.slackChannels = slackChannels;
+      catch (err) {
+        const msg = 'Error occurred in updating global notification';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'post-globalNotification-failed'));
       }
 
-      setting.__t = notifyType;
-      setting.triggerPath = triggerPath;
-      setting.triggerEvents = triggerEvents || [];
-
-      const createdNotification = await setting.save();
-
-      const parameters = { action: SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-
-      return res.apiv3({ createdNotification });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating global notification';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'post-globalNotification-failed'));
-    }
-
-  });
+    });
 
 
   /**
@@ -544,34 +566,41 @@ module.exports = (crowi) => {
    *                  $ref: '#/components/schemas/NotifyForPageGrant'
    */
   // eslint-disable-next-line max-len
-  router.put('/notify-for-page-grant', loginRequiredStrictly, adminRequired, addActivity, validator.notifyForPageGrant, apiV3FormValidator, async(req, res) => {
-
-    let requestParams = {
-      'notification:owner-page:isEnabled': req.body.isNotificationForOwnerPageEnabled,
-      'notification:group-page:isEnabled': req.body.isNotificationForGroupPageEnabled,
-    };
+  router.put('/notify-for-page-grant',
+    accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
+    Strictly,
+    adminRequired,
+    addActivity,
+    validator.notifyForPageGrant,
+    apiV3FormValidator,
+    async(req, res) => {
+
+      let requestParams = {
+        'notification:owner-page:isEnabled': req.body.isNotificationForOwnerPageEnabled,
+        'notification:group-page:isEnabled': req.body.isNotificationForGroupPageEnabled,
+      };
 
-    requestParams = removeNullPropertyFromObject(requestParams);
+      requestParams = removeNullPropertyFromObject(requestParams);
 
-    try {
-      await configManager.updateConfigs(requestParams);
-      const responseParams = {
-        isNotificationForOwnerPageEnabled: await crowi.configManager.getConfig('notification:owner-page:isEnabled'),
-        isNotificationForGroupPageEnabled: await crowi.configManager.getConfig('notification:group-page:isEnabled'),
-      };
+      try {
+        await configManager.updateConfigs(requestParams);
+        const responseParams = {
+          isNotificationForOwnerPageEnabled: await crowi.configManager.getConfig('notification:owner-page:isEnabled'),
+          isNotificationForGroupPageEnabled: await crowi.configManager.getConfig('notification:group-page:isEnabled'),
+        };
 
-      const parameters = { action: SupportedAction.ACTION_ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
+        const parameters = { action: SupportedAction.ACTION_ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
 
-      return res.apiv3({ responseParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating notify for page grant';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-notify-for-page-grant-failed'));
-    }
+        return res.apiv3({ responseParams });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating notify for page grant';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-notify-for-page-grant-failed'));
+      }
 
-  });
+    });
 
   /**
    * @swagger
@@ -609,35 +638,40 @@ module.exports = (crowi) => {
    *                      type: string
    *                      description: notification id
    */
-  router.put('/global-notification/:id/enabled', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
-    const { id } = req.params;
-    const { isEnabled } = req.body;
+  router.put('/global-notification/:id/enabled',
+    accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
+    Strictly,
+    adminRequired,
+    addActivity,
+    async(req, res) => {
+      const { id } = req.params;
+      const { isEnabled } = req.body;
+
+      try {
+        if (isEnabled) {
+          await GlobalNotificationSetting.enable(id);
+        }
+        else {
+          await GlobalNotificationSetting.disable(id);
+        }
+
+        const parameters = {
+          action: isEnabled
+            ? SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ENABLED
+            : SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DISABLED,
+        };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3({ id });
 
-    try {
-      if (isEnabled) {
-        await GlobalNotificationSetting.enable(id);
       }
-      else {
-        await GlobalNotificationSetting.disable(id);
+      catch (err) {
+        const msg = 'Error occurred in toggle of global notification';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'toggle-globalNotification-failed'));
       }
 
-      const parameters = {
-        action: isEnabled
-          ? SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ENABLED
-          : SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DISABLED,
-      };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-
-      return res.apiv3({ id });
-
-    }
-    catch (err) {
-      const msg = 'Error occurred in toggle of global notification';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'toggle-globalNotification-failed'));
-    }
-
-  });
+    });
 
   /**
   * @swagger
@@ -664,24 +698,29 @@ module.exports = (crowi) => {
   *                  description: deleted notification
   *                  $ref: '#/components/schemas/GlobalNotification'
   */
-  router.delete('/global-notification/:id', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
-    const { id } = req.params;
+  router.delete('/global-notification/:id',
+    accessTokenParser([SCOPE.WRITE.ADMIN.EXTERNAL_NOTIFICATION]),
+    Strictly,
+    adminRequired,
+    addActivity,
+    async(req, res) => {
+      const { id } = req.params;
 
-    try {
-      const deletedNotificaton = await GlobalNotificationSetting.findOneAndRemove({ _id: id });
+      try {
+        const deletedNotificaton = await GlobalNotificationSetting.findOneAndRemove({ _id: id });
 
-      const parameters = { action: SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
+        const parameters = { action: SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
 
-      return res.apiv3(deletedNotificaton);
-    }
-    catch (err) {
-      const msg = 'Error occurred in delete global notification';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'delete-globalNotification-failed'));
-    }
+        return res.apiv3(deletedNotificaton);
+      }
+      catch (err) {
+        const msg = 'Error occurred in delete global notification';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'delete-globalNotification-failed'));
+      }
 
-  });
+    });
 
   return router;
 };

+ 5 - 4
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -9,6 +9,7 @@ import { query, oneOf } from 'express-validator';
 import type { HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
 
+import { SCOPE } from '~/interfaces/scope';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { configManager } from '~/server/service/config-manager';
 import type { IPageGrantService } from '~/server/service/page-grant';
@@ -66,7 +67,7 @@ const routerFactory = (crowi: Crowi): Router => {
   const router = express.Router();
 
 
-  router.get('/root', accessTokenParser(), loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+  router.get('/root', accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const Page = mongoose.model<IPage, PageModel>('Page');
 
     let rootPage;
@@ -81,7 +82,7 @@ const routerFactory = (crowi: Crowi): Router => {
   });
 
   // eslint-disable-next-line max-len
-  router.get('/ancestors-children', accessTokenParser(), loginRequired, ...validator.pagePathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
+  router.get('/ancestors-children', accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequired, ...validator.pagePathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
     const { path } = req.query;
 
     const pageService = crowi.pageService;
@@ -100,7 +101,7 @@ const routerFactory = (crowi: Crowi): Router => {
    * In most cases, using id should be prioritized
    */
   // eslint-disable-next-line max-len
-  router.get('/children', accessTokenParser(), loginRequired, validator.pageIdOrPathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
+  router.get('/children', accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequired, validator.pageIdOrPathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const { id, path } = req.query;
 
     const pageService = crowi.pageService;
@@ -121,7 +122,7 @@ const routerFactory = (crowi: Crowi): Router => {
   });
 
   // eslint-disable-next-line max-len
-  router.get('/info', accessTokenParser(), loginRequired, validator.pageIdsOrPathRequired, validator.infoParams, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
+  router.get('/info', accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequired, validator.pageIdsOrPathRequired, validator.infoParams, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const {
       pageIds, path, attachBookmarkCount: attachBookmarkCountParam, attachShortBody: attachShortBodyParam,
     } = req.query;

+ 2 - 1
apps/app/src/server/routes/apiv3/page/check-page-existence.ts

@@ -6,6 +6,7 @@ import type { ValidationChain } from 'express-validator';
 import { query } from 'express-validator';
 import mongoose from 'mongoose';
 
+import { SCOPE } from '~/interfaces/scope';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
@@ -39,7 +40,7 @@ export const checkPageExistenceHandlersFactory: CreatePageHandlersFactory = (cro
   ];
 
   return [
-    accessTokenParser(), loginRequired,
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]), loginRequired,
     validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
       const { path } = req.query;

+ 2 - 1
apps/app/src/server/routes/apiv3/page/create-page.ts

@@ -16,6 +16,7 @@ import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import type { IApiv3PageCreateParams } from '~/interfaces/apiv3';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
 import type { IOptionsForCreate } from '~/interfaces/page';
+import { SCOPE } from '~/interfaces/scope';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
@@ -217,7 +218,7 @@ export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
   const addActivity = generateAddActivityMiddleware();
 
   return [
-    accessTokenParser(), loginRequiredStrictly, excludeReadOnlyUser, addActivity,
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]), loginRequiredStrictly, excludeReadOnlyUser, addActivity,
     validator, apiV3FormValidator,
     async(req: CreatePageRequest, res: ApiV3Response) => {
       const {

+ 2 - 1
apps/app/src/server/routes/apiv3/page/get-page-paths-with-descendant-count.ts

@@ -4,6 +4,7 @@ import type { ValidationChain } from 'express-validator';
 import { query } from 'express-validator';
 import mongoose from 'mongoose';
 
+import { SCOPE } from '~/interfaces/scope';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import type { PageModel } from '~/server/models/page';
@@ -56,7 +57,7 @@ export const getPagePathsWithDescendantCountFactory: GetPagePathsWithDescendantC
   ];
 
   return [
-    accessTokenParser, loginRequiredStrictly,
+    accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequiredStrictly,
     validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
       const {

+ 2 - 1
apps/app/src/server/routes/apiv3/page/get-yjs-data.ts

@@ -5,6 +5,7 @@ import type { ValidationChain } from 'express-validator';
 import { param } from 'express-validator';
 import mongoose from 'mongoose';
 
+import { SCOPE } from '~/interfaces/scope';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import type { PageModel } from '~/server/models/page';
@@ -34,7 +35,7 @@ export const getYjsDataHandlerFactory: GetYjsDataHandlerFactory = (crowi) => {
   ];
 
   return [
-    accessTokenParser(), loginRequiredStrictly,
+    accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequiredStrictly,
     validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
       const { pageId } = req.params;

+ 161 - 152
apps/app/src/server/routes/apiv3/page/index.ts

@@ -16,6 +16,7 @@ import sanitize from 'sanitize-filename';
 
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import type { IPageGrantData } from '~/interfaces/page';
+import { SCOPE } from '~/interfaces/scope';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
@@ -212,7 +213,7 @@ module.exports = (crowi) => {
    *                schema:
    *                  $ref: '#/components/schemas/Page'
    */
-  router.get('/', certifySharedPage, accessTokenParser(), loginRequired, validator.getPage, apiV3FormValidator, async(req, res) => {
+  router.get('/', accessTokenParser([SCOPE.READ.FEATURES.PAGE]), certifySharedPage, loginRequired, validator.getPage, apiV3FormValidator, async(req, res) => {
     const { user, isSharedPage } = req;
     const {
       pageId, path, findAll, revisionId, shareLinkId, includeEmpty,
@@ -443,51 +444,52 @@ module.exports = (crowi) => {
    *                schema:
    *                  $ref: '#/components/schemas/Page'
    */
-  router.put('/likes', accessTokenParser(), loginRequiredStrictly, addActivity, validator.likes, apiV3FormValidator, async(req, res) => {
-    const { pageId, bool: isLiked } = req.body;
+  router.put('/likes', accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]), loginRequiredStrictly, addActivity,
+    validator.likes, apiV3FormValidator, async(req, res) => {
+      const { pageId, bool: isLiked } = req.body;
 
-    let page;
-    try {
-      page = await Page.findByIdAndViewer(pageId, req.user);
-      if (page == null) {
-        return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
-      }
+      let page;
+      try {
+        page = await Page.findByIdAndViewer(pageId, req.user);
+        if (page == null) {
+          return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
+        }
 
-      if (isLiked) {
-        page = await page.like(req.user);
+        if (isLiked) {
+          page = await page.like(req.user);
+        }
+        else {
+          page = await page.unlike(req.user);
+        }
       }
-      else {
-        page = await page.unlike(req.user);
+      catch (err) {
+        logger.error('update-like-failed', err);
+        return res.apiv3Err(err, 500);
       }
-    }
-    catch (err) {
-      logger.error('update-like-failed', err);
-      return res.apiv3Err(err, 500);
-    }
 
-    const result = { page, seenUser: page.seenUsers };
+      const result = { page, seenUser: page.seenUsers };
 
-    const parameters = {
-      targetModel: SupportedTargetModel.MODEL_PAGE,
-      target: page,
-      action: isLiked ? SupportedAction.ACTION_PAGE_LIKE : SupportedAction.ACTION_PAGE_UNLIKE,
-    };
+      const parameters = {
+        targetModel: SupportedTargetModel.MODEL_PAGE,
+        target: page,
+        action: isLiked ? SupportedAction.ACTION_PAGE_LIKE : SupportedAction.ACTION_PAGE_UNLIKE,
+      };
 
-    activityEvent.emit('update', res.locals.activity._id, parameters, page, preNotifyService.generatePreNotify);
+      activityEvent.emit('update', res.locals.activity._id, parameters, page, preNotifyService.generatePreNotify);
 
 
-    res.apiv3({ result });
+      res.apiv3({ result });
 
-    if (isLiked) {
-      try {
+      if (isLiked) {
+        try {
         // global notification
-        await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_LIKE, page, req.user);
-      }
-      catch (err) {
-        logger.error('Like notification failed', err);
+          await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_LIKE, page, req.user);
+        }
+        catch (err) {
+          logger.error('Like notification failed', err);
+        }
       }
-    }
-  });
+    });
 
   /**
    * @swagger
@@ -513,7 +515,7 @@ module.exports = (crowi) => {
    *          500:
    *            description: Internal server error.
    */
-  router.get('/info', certifySharedPage, loginRequired, validator.info, apiV3FormValidator, async(req, res) => {
+  router.get('/info', accessTokenParser([SCOPE.READ.FEATURES.PAGE]), certifySharedPage, loginRequired, validator.info, apiV3FormValidator, async(req, res) => {
     const { user, isSharedPage } = req;
     const { pageId } = req.query;
 
@@ -563,74 +565,75 @@ module.exports = (crowi) => {
    *          500:
    *            description: Internal server error.
    */
-  router.get('/grant-data', loginRequiredStrictly, validator.getGrantData, apiV3FormValidator, async(req, res) => {
-    const { pageId } = req.query;
+  router.get('/grant-data', accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequiredStrictly,
+    validator.getGrantData, apiV3FormValidator, async(req, res) => {
+      const { pageId } = req.query;
 
-    const Page = mongoose.model<IPage, PageModel>('Page');
-    const pageGrantService = crowi.pageGrantService as IPageGrantService;
+      const Page = mongoose.model<IPage, PageModel>('Page');
+      const pageGrantService = crowi.pageGrantService as IPageGrantService;
 
-    const page = await Page.findByIdAndViewer(pageId, req.user, null, false);
+      const page = await Page.findByIdAndViewer(pageId, req.user, null, false);
 
-    if (page == null) {
+      if (page == null) {
       // Empty page should not be related to grant API
-      return res.apiv3Err(new ErrorV3('Page is unreachable or empty.', 'page_unreachable_or_empty'), 400);
-    }
-
-    const {
-      path, grant, grantedUsers, grantedGroups,
-    } = page;
-    let isGrantNormalized = false;
-    try {
-      const grantedUsersId = grantedUsers.map(ref => getIdForRef(ref));
-      isGrantNormalized = await pageGrantService.isGrantNormalized(req.user, path, grant, grantedUsersId, grantedGroups, false, false);
-    }
-    catch (err) {
-      logger.error('Error occurred while processing isGrantNormalized.', err);
-      return res.apiv3Err(err, 500);
-    }
+        return res.apiv3Err(new ErrorV3('Page is unreachable or empty.', 'page_unreachable_or_empty'), 400);
+      }
 
-    const currentPageGroupGrantData = await pageGrantService.getPageGroupGrantData(page, req.user);
-    const currentPageGrant: IPageGrantData = {
-      grant: page.grant,
-      groupGrantData: currentPageGroupGrantData,
-    };
+      const {
+        path, grant, grantedUsers, grantedGroups,
+      } = page;
+      let isGrantNormalized = false;
+      try {
+        const grantedUsersId = grantedUsers.map(ref => getIdForRef(ref));
+        isGrantNormalized = await pageGrantService.isGrantNormalized(req.user, path, grant, grantedUsersId, grantedGroups, false, false);
+      }
+      catch (err) {
+        logger.error('Error occurred while processing isGrantNormalized.', err);
+        return res.apiv3Err(err, 500);
+      }
 
-    // page doesn't have parent page
-    if (page.parent == null) {
-      const grantData = {
-        isForbidden: false,
-        currentPageGrant,
-        parentPageGrant: null,
+      const currentPageGroupGrantData = await pageGrantService.getPageGroupGrantData(page, req.user);
+      const currentPageGrant: IPageGrantData = {
+        grant: page.grant,
+        groupGrantData: currentPageGroupGrantData,
       };
-      return res.apiv3({ isGrantNormalized, grantData });
-    }
 
-    const parentPage = await Page.findByIdAndViewer(getIdForRef(page.parent), req.user, null, false);
+      // page doesn't have parent page
+      if (page.parent == null) {
+        const grantData = {
+          isForbidden: false,
+          currentPageGrant,
+          parentPageGrant: null,
+        };
+        return res.apiv3({ isGrantNormalized, grantData });
+      }
+
+      const parentPage = await Page.findByIdAndViewer(getIdForRef(page.parent), req.user, null, false);
+
+      // user isn't allowed to see parent's grant
+      if (parentPage == null) {
+        const grantData = {
+          isForbidden: true,
+          currentPageGrant,
+          parentPageGrant: null,
+        };
+        return res.apiv3({ isGrantNormalized, grantData });
+      }
+
+      const parentPageGroupGrantData = await pageGrantService.getPageGroupGrantData(parentPage, req.user);
+      const parentPageGrant: IPageGrantData = {
+        grant,
+        groupGrantData: parentPageGroupGrantData,
+      };
 
-    // user isn't allowed to see parent's grant
-    if (parentPage == null) {
       const grantData = {
-        isForbidden: true,
+        isForbidden: false,
         currentPageGrant,
-        parentPageGrant: null,
+        parentPageGrant,
       };
-      return res.apiv3({ isGrantNormalized, grantData });
-    }
-
-    const parentPageGroupGrantData = await pageGrantService.getPageGroupGrantData(parentPage, req.user);
-    const parentPageGrant: IPageGrantData = {
-      grant,
-      groupGrantData: parentPageGroupGrantData,
-    };
-
-    const grantData = {
-      isForbidden: false,
-      currentPageGrant,
-      parentPageGrant,
-    };
 
-    return res.apiv3({ isGrantNormalized, grantData });
-  });
+      return res.apiv3({ isGrantNormalized, grantData });
+    });
 
   // Check if non user related groups are granted page access.
   // If specified page does not exist, check the closest ancestor.
@@ -665,7 +668,8 @@ module.exports = (crowi) => {
    *         500:
    *           description: Internal server error.
    */
-  router.get('/non-user-related-groups-granted', loginRequiredStrictly, validator.nonUserRelatedGroupsGranted, apiV3FormValidator,
+  router.get('/non-user-related-groups-granted', accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequiredStrictly,
+    validator.nonUserRelatedGroupsGranted, apiV3FormValidator,
     async(req, res: ApiV3Response) => {
       const { user } = req;
       const path = normalizePath(req.query.path);
@@ -735,28 +739,29 @@ module.exports = (crowi) => {
    *         500:
    *           description: Internal server error.
    */
-  router.get('/applicable-grant', loginRequiredStrictly, validator.applicableGrant, apiV3FormValidator, async(req, res) => {
-    const { pageId } = req.query;
+  router.get('/applicable-grant', accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequiredStrictly, validator.applicableGrant, apiV3FormValidator,
+    async(req, res) => {
+      const { pageId } = req.query;
 
-    const Page = crowi.model('Page');
-    const page = await Page.findByIdAndViewer(pageId, req.user, null);
+      const Page = crowi.model('Page');
+      const page = await Page.findByIdAndViewer(pageId, req.user, null);
 
-    if (page == null) {
+      if (page == null) {
       // Empty page should not be related to grant API
-      return res.apiv3Err(new ErrorV3('Page is unreachable or empty.', 'page_unreachable_or_empty'), 400);
-    }
+        return res.apiv3Err(new ErrorV3('Page is unreachable or empty.', 'page_unreachable_or_empty'), 400);
+      }
 
-    let data;
-    try {
-      data = await crowi.pageGrantService.calcApplicableGrantData(page, req.user);
-    }
-    catch (err) {
-      logger.error('Error occurred while processing calcApplicableGrantData.', err);
-      return res.apiv3Err(err, 500);
-    }
+      let data;
+      try {
+        data = await crowi.pageGrantService.calcApplicableGrantData(page, req.user);
+      }
+      catch (err) {
+        logger.error('Error occurred while processing calcApplicableGrantData.', err);
+        return res.apiv3Err(err, 500);
+      }
 
-    return res.apiv3(data);
-  });
+      return res.apiv3(data);
+    });
 
   /**
    * @swagger
@@ -795,32 +800,34 @@ module.exports = (crowi) => {
    *               schema:
    *                 $ref: '#/components/schemas/Page'
    */
-  router.put('/:pageId/grant', loginRequiredStrictly, excludeReadOnlyUser, validator.updateGrant, apiV3FormValidator, async(req, res) => {
-    const { pageId } = req.params;
-    const { grant, userRelatedGrantedGroups } = req.body;
+  router.put('/:pageId/grant', accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]), loginRequiredStrictly, excludeReadOnlyUser,
+    validator.updateGrant, apiV3FormValidator,
+    async(req, res) => {
+      const { pageId } = req.params;
+      const { grant, userRelatedGrantedGroups } = req.body;
 
-    const Page = crowi.model('Page');
+      const Page = crowi.model('Page');
 
-    const page = await Page.findByIdAndViewer(pageId, req.user, null, false);
+      const page = await Page.findByIdAndViewer(pageId, req.user, null, false);
 
-    if (page == null) {
+      if (page == null) {
       // Empty page should not be related to grant API
-      return res.apiv3Err(new ErrorV3('Page is unreachable or empty.', 'page_unreachable_or_empty'), 400);
-    }
+        return res.apiv3Err(new ErrorV3('Page is unreachable or empty.', 'page_unreachable_or_empty'), 400);
+      }
 
-    let data;
-    try {
-      const shouldUseV4Process = false;
-      const grantData = { grant, userRelatedGrantedGroups };
-      data = await crowi.pageService.updateGrant(page, req.user, grantData, shouldUseV4Process);
-    }
-    catch (err) {
-      logger.error('Error occurred while processing calcApplicableGrantData.', err);
-      return res.apiv3Err(err, 500);
-    }
+      let data;
+      try {
+        const shouldUseV4Process = false;
+        const grantData = { grant, userRelatedGrantedGroups };
+        data = await crowi.pageService.updateGrant(page, req.user, grantData, shouldUseV4Process);
+      }
+      catch (err) {
+        logger.error('Error occurred while processing calcApplicableGrantData.', err);
+        return res.apiv3Err(err, 500);
+      }
 
-    return res.apiv3(data);
-  });
+      return res.apiv3(data);
+    });
 
   /**
   * @swagger
@@ -835,7 +842,7 @@ module.exports = (crowi) => {
   *          200:
   *            description: Return page's markdown
   */
-  router.get('/export/:pageId', loginRequiredStrictly, validator.export, async(req, res) => {
+  router.get('/export/:pageId', accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequiredStrictly, validator.export, async(req, res) => {
     const pageId: string = req.params.pageId;
     const { format, revisionId = null } = req.query;
     let revision;
@@ -962,7 +969,7 @@ module.exports = (crowi) => {
    *          500:
    *            description: Internal server error.
    */
-  router.get('/exist-paths', loginRequired, validator.exist, apiV3FormValidator, async(req, res) => {
+  router.get('/exist-paths', accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequired, validator.exist, apiV3FormValidator, async(req, res) => {
     const { fromPath, toPath } = req.query;
 
     try {
@@ -1016,31 +1023,33 @@ module.exports = (crowi) => {
    *          500:
    *            description: Internal server error.
    */
-  router.put('/subscribe', accessTokenParser(), loginRequiredStrictly, addActivity, validator.subscribe, apiV3FormValidator, async(req, res) => {
-    const { pageId, status } = req.body;
-    const userId = req.user._id;
+  router.put('/subscribe', accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]), loginRequiredStrictly, addActivity,
+    validator.subscribe, apiV3FormValidator,
+    async(req, res) => {
+      const { pageId, status } = req.body;
+      const userId = req.user._id;
 
-    try {
-      const subscription = await Subscription.subscribeByPageId(userId, pageId, status);
+      try {
+        const subscription = await Subscription.subscribeByPageId(userId, pageId, status);
 
-      const parameters = {};
-      if (SubscriptionStatusType.SUBSCRIBE === status) {
-        Object.assign(parameters, { action: SupportedAction.ACTION_PAGE_SUBSCRIBE });
-      }
-      else if (SubscriptionStatusType.UNSUBSCRIBE === status) {
-        Object.assign(parameters, { action: SupportedAction.ACTION_PAGE_UNSUBSCRIBE });
+        const parameters = {};
+        if (SubscriptionStatusType.SUBSCRIBE === status) {
+          Object.assign(parameters, { action: SupportedAction.ACTION_PAGE_SUBSCRIBE });
+        }
+        else if (SubscriptionStatusType.UNSUBSCRIBE === status) {
+          Object.assign(parameters, { action: SupportedAction.ACTION_PAGE_UNSUBSCRIBE });
+        }
+        if ('action' in parameters) {
+          activityEvent.emit('update', res.locals.activity._id, parameters);
+        }
+
+        return res.apiv3({ subscription });
       }
-      if ('action' in parameters) {
-        activityEvent.emit('update', res.locals.activity._id, parameters);
+      catch (err) {
+        logger.error('Failed to update subscribe status', err);
+        return res.apiv3Err(err, 500);
       }
-
-      return res.apiv3({ subscription });
-    }
-    catch (err) {
-      logger.error('Failed to update subscribe status', err);
-      return res.apiv3Err(err, 500);
-    }
-  });
+    });
 
 
   /**
@@ -1076,7 +1085,7 @@ module.exports = (crowi) => {
    *                   page:
    *                     $ref: '#/components/schemas/Page'
    */
-  router.put('/:pageId/content-width', accessTokenParser(), loginRequiredStrictly, excludeReadOnlyUser,
+  router.put('/:pageId/content-width', accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]), loginRequiredStrictly, excludeReadOnlyUser,
     validator.contentWidth, apiV3FormValidator, async(req, res) => {
       const { pageId } = req.params;
       const { expandContentWidth } = req.body;

+ 2 - 1
apps/app/src/server/routes/apiv3/page/publish-page.ts

@@ -5,6 +5,7 @@ import type { ValidationChain } from 'express-validator';
 import { param } from 'express-validator';
 import mongoose from 'mongoose';
 
+import { SCOPE } from '~/interfaces/scope';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import type { PageModel } from '~/server/models/page';
@@ -38,7 +39,7 @@ export const publishPageHandlersFactory: PublishPageHandlersFactory = (crowi) =>
   ];
 
   return [
-    accessTokenParser(), loginRequiredStrictly,
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]), loginRequiredStrictly,
     validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
       const { pageId } = req.params;

+ 2 - 1
apps/app/src/server/routes/apiv3/page/sync-latest-revision-body-to-yjs-draft.ts

@@ -5,6 +5,7 @@ import type { ValidationChain } from 'express-validator';
 import { param, body } from 'express-validator';
 import mongoose from 'mongoose';
 
+import { SCOPE } from '~/interfaces/scope';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import type { PageModel } from '~/server/models/page';
@@ -39,7 +40,7 @@ export const syncLatestRevisionBodyToYjsDraftHandlerFactory: SyncLatestRevisionB
   ];
 
   return [
-    accessTokenParser(), loginRequiredStrictly,
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]), loginRequiredStrictly,
     validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
       const { pageId } = req.params;

+ 2 - 1
apps/app/src/server/routes/apiv3/page/unpublish-page.ts

@@ -5,6 +5,7 @@ import type { ValidationChain } from 'express-validator';
 import { param } from 'express-validator';
 import mongoose from 'mongoose';
 
+import { SCOPE } from '~/interfaces/scope';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import type { PageModel } from '~/server/models/page';
@@ -38,7 +39,7 @@ export const unpublishPageHandlersFactory: UnpublishPageHandlersFactory = (crowi
   ];
 
   return [
-    accessTokenParser(), loginRequiredStrictly,
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]), loginRequiredStrictly,
     validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
       const { pageId } = req.params;

+ 2 - 1
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -15,6 +15,7 @@ import { isAiEnabled } from '~/features/openai/server/services';
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import { type IApiv3PageUpdateParams, PageUpdateErrorCode } from '~/interfaces/apiv3';
 import type { IOptionsForUpdate } from '~/interfaces/page';
+import { SCOPE } from '~/interfaces/scope';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
@@ -133,7 +134,7 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
   const addActivity = generateAddActivityMiddleware();
 
   return [
-    accessTokenParser(), loginRequiredStrictly, excludeReadOnlyUser, addActivity,
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]), loginRequiredStrictly, excludeReadOnlyUser, addActivity,
     validator, apiV3FormValidator,
     async(req: UpdatePageRequest, res: ApiV3Response) => {
       const {

+ 308 - 237
apps/app/src/server/routes/apiv3/pages/index.js

@@ -9,6 +9,7 @@ import { body, query } from 'express-validator';
 
 import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
+import { SCOPE } from '~/interfaces/scope';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import PageTagRelation from '~/server/models/page-tag-relation';
@@ -151,7 +152,7 @@ module.exports = (crowi) => {
    *          200:
    *            description: Return pages recently updated
    */
-  router.get('/recent', accessTokenParser(), loginRequired, validator.recent, apiV3FormValidator, async(req, res) => {
+  router.get('/recent', accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequired, validator.recent, apiV3FormValidator, async(req, res) => {
     const limit = parseInt(req.query.limit) || 20;
     const offset = parseInt(req.query.offset) || 0;
     const includeWipPage = req.query.includeWipPage === 'true'; // Need validation using express-validator
@@ -270,83 +271,91 @@ module.exports = (crowi) => {
    *          409:
    *            description: page path is already existed
    */
-  router.put('/rename', accessTokenParser(), loginRequiredStrictly, excludeReadOnlyUser, validator.renamePage, apiV3FormValidator, async(req, res) => {
-    const { pageId, revisionId } = req.body;
+  router.put(
+    '/rename',
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]),
+    loginRequiredStrictly,
+    excludeReadOnlyUser,
+    validator.renamePage,
+    apiV3FormValidator,
+    async(req, res) => {
+      const { pageId, revisionId } = req.body;
 
-    let newPagePath = normalizePath(req.body.newPagePath);
+      let newPagePath = normalizePath(req.body.newPagePath);
 
-    const options = {
-      isRecursively: req.body.isRecursively,
-      createRedirectPage: req.body.isRenameRedirect,
-      updateMetadata: req.body.updateMetadata,
-      isMoveMode: req.body.isMoveMode,
-    };
+      const options = {
+        isRecursively: req.body.isRecursively,
+        createRedirectPage: req.body.isRenameRedirect,
+        updateMetadata: req.body.updateMetadata,
+        isMoveMode: req.body.isMoveMode,
+      };
 
-    const activityParameters = {
-      ip: req.ip,
-      endpoint: req.originalUrl,
-    };
+      const activityParameters = {
+        ip: req.ip,
+        endpoint: req.originalUrl,
+      };
 
-    if (!isCreatablePage(newPagePath)) {
-      return res.apiv3Err(new ErrorV3(`Could not use the path '${newPagePath}'`, 'invalid_path'), 409);
-    }
+      if (!isCreatablePage(newPagePath)) {
+        return res.apiv3Err(new ErrorV3(`Could not use the path '${newPagePath}'`, 'invalid_path'), 409);
+      }
 
-    if (isUserPage(newPagePath)) {
-      const isExistUser = await User.isExistUserByUserPagePath(newPagePath);
-      if (!isExistUser) {
-        return res.apiv3Err("Unable to rename a page under a non-existent user's user page");
+      if (isUserPage(newPagePath)) {
+        const isExistUser = await User.isExistUserByUserPagePath(newPagePath);
+        if (!isExistUser) {
+          return res.apiv3Err("Unable to rename a page under a non-existent user's user page");
+        }
       }
-    }
 
-    // check whether path starts slash
-    newPagePath = addHeadingSlash(newPagePath);
+      // check whether path starts slash
+      newPagePath = addHeadingSlash(newPagePath);
 
-    const isExist = await Page.exists({ path: newPagePath, isEmpty: false });
-    if (isExist) {
+      const isExist = await Page.exists({ path: newPagePath, isEmpty: false });
+      if (isExist) {
       // if page found, cannot rename to that path
-      return res.apiv3Err(new ErrorV3(`${newPagePath} already exists`, 'already_exists'), 409);
-    }
+        return res.apiv3Err(new ErrorV3(`${newPagePath} already exists`, 'already_exists'), 409);
+      }
 
-    let page;
-    let renamedPage;
+      let page;
+      let renamedPage;
 
-    try {
-      page = await Page.findByIdAndViewer(pageId, req.user, null, true);
-      options.isRecursively = page.descendantCount > 0;
+      try {
+        page = await Page.findByIdAndViewer(pageId, req.user, null, true);
+        options.isRecursively = page.descendantCount > 0;
 
-      if (page == null) {
-        return res.apiv3Err(new ErrorV3(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 401);
-      }
+        if (page == null) {
+          return res.apiv3Err(new ErrorV3(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 401);
+        }
 
-      // empty page does not require revisionId validation
-      if (!page.isEmpty && revisionId == null) {
-        return res.apiv3Err(new ErrorV3('revisionId must be a mongoId', 'invalid_body'), 400);
-      }
+        // empty page does not require revisionId validation
+        if (!page.isEmpty && revisionId == null) {
+          return res.apiv3Err(new ErrorV3('revisionId must be a mongoId', 'invalid_body'), 400);
+        }
 
-      if (!page.isEmpty && !page.isUpdatable(revisionId)) {
-        return res.apiv3Err(new ErrorV3('Someone could update this page, so couldn\'t delete.', 'notfound_or_forbidden'), 409);
-      }
-      renamedPage = await crowi.pageService.renamePage(page, newPagePath, req.user, options, activityParameters);
+        if (!page.isEmpty && !page.isUpdatable(revisionId)) {
+          return res.apiv3Err(new ErrorV3('Someone could update this page, so couldn\'t delete.', 'notfound_or_forbidden'), 409);
+        }
+        renamedPage = await crowi.pageService.renamePage(page, newPagePath, req.user, options, activityParameters);
 
-      // Respond before sending notification
-      const result = { page: serializePageSecurely(renamedPage ?? page) };
-      res.apiv3(result);
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
-    }
+        // Respond before sending notification
+        const result = { page: serializePageSecurely(renamedPage ?? page) };
+        res.apiv3(result);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
+      }
 
-    try {
+      try {
       // global notification
-      await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_MOVE, renamedPage, req.user, {
-        oldPath: page.path,
-      });
-    }
-    catch (err) {
-      logger.error('Move notification failed', err);
-    }
-  });
+        await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_MOVE, renamedPage, req.user, {
+          oldPath: page.path,
+        });
+      }
+      catch (err) {
+        logger.error('Move notification failed', err);
+      }
+    },
+  );
 
   /**
     * @swagger
@@ -370,7 +379,12 @@ module.exports = (crowi) => {
     *            content:
     *              description: Empty response
     */
-  router.post('/resume-rename', accessTokenParser(), loginRequiredStrictly, validator.resumeRenamePage, apiV3FormValidator,
+  router.post(
+    '/resume-rename',
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]),
+    loginRequiredStrictly,
+    validator.resumeRenamePage,
+    apiV3FormValidator,
     async(req, res) => {
 
       const { pageId } = req.body;
@@ -399,7 +413,8 @@ module.exports = (crowi) => {
         return res.apiv3Err(err, 500);
       }
       return res.apiv3();
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -420,54 +435,62 @@ module.exports = (crowi) => {
    *                      items:
    *                        $ref: '#/components/schemas/Page'
    */
-  router.delete('/empty-trash', accessTokenParser(), loginRequired, excludeReadOnlyUser, addActivity, apiV3FormValidator, async(req, res) => {
-    const options = {};
+  router.delete(
+    '/empty-trash',
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]),
+    loginRequired,
+    excludeReadOnlyUser,
+    addActivity,
+    apiV3FormValidator,
+    async(req, res) => {
+      const options = {};
 
-    const pagesInTrash = await crowi.pageService.findAllTrashPages(req.user);
+      const pagesInTrash = await crowi.pageService.findAllTrashPages(req.user);
 
-    const deletablePages = crowi.pageService.filterPagesByCanDeleteCompletely(pagesInTrash, req.user, true);
+      const deletablePages = crowi.pageService.filterPagesByCanDeleteCompletely(pagesInTrash, req.user, true);
 
-    if (deletablePages.length === 0) {
-      const msg = 'No pages can be deleted.';
-      return res.apiv3Err(new ErrorV3(msg), 500);
-    }
+      if (deletablePages.length === 0) {
+        const msg = 'No pages can be deleted.';
+        return res.apiv3Err(new ErrorV3(msg), 500);
+      }
 
-    const parameters = { action: SupportedAction.ACTION_PAGE_EMPTY_TRASH };
+      const parameters = { action: SupportedAction.ACTION_PAGE_EMPTY_TRASH };
 
-    // when some pages are not deletable
-    if (deletablePages.length < pagesInTrash.length) {
-      try {
-        const options = { isCompletely: true, isRecursively: true };
-        await crowi.pageService.deleteMultiplePages(deletablePages, req.user, options);
+      // when some pages are not deletable
+      if (deletablePages.length < pagesInTrash.length) {
+        try {
+          const options = { isCompletely: true, isRecursively: true };
+          await crowi.pageService.deleteMultiplePages(deletablePages, req.user, options);
 
-        activityEvent.emit('update', res.locals.activity._id, parameters);
+          activityEvent.emit('update', res.locals.activity._id, parameters);
 
-        return res.apiv3({ deletablePages });
-      }
-      catch (err) {
-        logger.error(err);
-        return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
+          return res.apiv3({ deletablePages });
+        }
+        catch (err) {
+          logger.error(err);
+          return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
+        }
       }
-    }
-    // when all pages are deletable
-    else {
-      try {
-        const activityParameters = {
-          ip: req.ip,
-          endpoint: req.originalUrl,
-        };
-        const pages = await crowi.pageService.emptyTrashPage(req.user, options, activityParameters);
+      // when all pages are deletable
+      else {
+        try {
+          const activityParameters = {
+            ip: req.ip,
+            endpoint: req.originalUrl,
+          };
+          const pages = await crowi.pageService.emptyTrashPage(req.user, options, activityParameters);
 
-        activityEvent.emit('update', res.locals.activity._id, parameters);
+          activityEvent.emit('update', res.locals.activity._id, parameters);
 
-        return res.apiv3({ pages });
-      }
-      catch (err) {
-        logger.error(err);
-        return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
+          return res.apiv3({ pages });
+        }
+        catch (err) {
+          logger.error(err);
+          return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
+        }
       }
-    }
-  });
+    },
+  );
 
   validator.displayList = [
     query('limit').if(value => value != null).isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
@@ -526,41 +549,47 @@ module.exports = (crowi) => {
     *                              lastUpdateUser:
     *                                $ref: '#/components/schemas/User'
     */
-  router.get('/list', accessTokenParser(), loginRequired, validator.displayList, apiV3FormValidator, async(req, res) => {
-
-    const { path } = req.query;
-    const limit = parseInt(req.query.limit) || await crowi.configManager.getConfig('customize:showPageLimitationS') || 10;
-    const page = req.query.page || 1;
-    const offset = (page - 1) * limit;
+  router.get(
+    '/list',
+    accessTokenParser([SCOPE.READ.FEATURES.PAGE]),
+    loginRequired,
+    validator.displayList,
+    apiV3FormValidator,
+    async(req, res) => {
+      const { path } = req.query;
+      const limit = parseInt(req.query.limit) || await crowi.configManager.getConfig('customize:showPageLimitationS') || 10;
+      const page = req.query.page || 1;
+      const offset = (page - 1) * limit;
 
-    let includeTrashed = false;
+      let includeTrashed = false;
 
-    if (isTrashPage(path)) {
-      includeTrashed = true;
-    }
+      if (isTrashPage(path)) {
+        includeTrashed = true;
+      }
 
-    const queryOptions = {
-      offset,
-      limit,
-      includeTrashed,
-    };
+      const queryOptions = {
+        offset,
+        limit,
+        includeTrashed,
+      };
 
-    try {
-      const result = await Page.findListWithDescendants(path, req.user, queryOptions);
+      try {
+        const result = await Page.findListWithDescendants(path, req.user, queryOptions);
 
-      result.pages.forEach((page) => {
-        if (page.lastUpdateUser != null && page.lastUpdateUser instanceof User) {
-          page.lastUpdateUser = serializeUserSecurely(page.lastUpdateUser);
-        }
-      });
+        result.pages.forEach((page) => {
+          if (page.lastUpdateUser != null && page.lastUpdateUser instanceof User) {
+            page.lastUpdateUser = serializeUserSecurely(page.lastUpdateUser);
+          }
+        });
 
-      return res.apiv3(result);
-    }
-    catch (err) {
-      logger.error('Failed to get Descendants Pages', err);
-      return res.apiv3Err(err, 500);
-    }
-  });
+        return res.apiv3(result);
+      }
+      catch (err) {
+        logger.error('Failed to get Descendants Pages', err);
+        return res.apiv3Err(err, 500);
+      }
+    },
+  );
 
   /**
    * @swagger
@@ -603,7 +632,14 @@ module.exports = (crowi) => {
    *          500:
    *            description: Internal server error.
    */
-  router.post('/duplicate', accessTokenParser(), loginRequiredStrictly, excludeReadOnlyUser, addActivity, validator.duplicatePage, apiV3FormValidator,
+  router.post(
+    '/duplicate',
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]),
+    loginRequiredStrictly,
+    excludeReadOnlyUser,
+    addActivity,
+    validator.duplicatePage,
+    apiV3FormValidator,
     async(req, res) => {
       const { pageId, isRecursively, onlyDuplicateUserRelatedResources } = req.body;
 
@@ -666,7 +702,8 @@ module.exports = (crowi) => {
       activityEvent.emit('update', res.locals.activity._id, parameters, page, preNotifyService.generatePreNotify);
 
       return res.apiv3(result);
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -700,21 +737,25 @@ module.exports = (crowi) => {
    *                      items:
    *                        $ref: '#/components/schemas/Page'
    */
-  router.get('/subordinated-list', accessTokenParser(), loginRequired, async(req, res) => {
-    const { path } = req.query;
-    const limit = parseInt(req.query.limit) || LIMIT_FOR_LIST;
-
-    try {
-      const pageData = await Page.findByPath(path, true);
-      const result = await Page.findManageableListWithDescendants(pageData, req.user, { limit });
+  router.get(
+    '/subordinated-list',
+    accessTokenParser([SCOPE.READ.FEATURES.PAGE]),
+    loginRequired,
+    async(req, res) => {
+      const { path } = req.query;
+      const limit = parseInt(req.query.limit) || LIMIT_FOR_LIST;
 
-      return res.apiv3({ subordinatedPages: result });
-    }
-    catch (err) {
-      return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
-    }
+      try {
+        const pageData = await Page.findByPath(path, true);
+        const result = await Page.findManageableListWithDescendants(pageData, req.user, { limit });
 
-  });
+        return res.apiv3({ subordinatedPages: result });
+      }
+      catch (err) {
+        return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
+      }
+    },
+  );
 
   /**
     * @swagger
@@ -760,59 +801,67 @@ module.exports = (crowi) => {
     *                      type: boolean
     *                      description: Whether pages were deleted completely
     */
-  router.post('/delete', accessTokenParser(), loginRequiredStrictly, excludeReadOnlyUser, validator.deletePages, apiV3FormValidator, async(req, res) => {
-    const {
-      pageIdToRevisionIdMap, isCompletely, isRecursively, isAnyoneWithTheLink,
-    } = req.body;
+  router.post(
+    '/delete',
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]),
+    loginRequiredStrictly,
+    excludeReadOnlyUser,
+    validator.deletePages,
+    apiV3FormValidator,
+    async(req, res) => {
+      const {
+        pageIdToRevisionIdMap, isCompletely, isRecursively, isAnyoneWithTheLink,
+      } = req.body;
 
-    const pageIds = Object.keys(pageIdToRevisionIdMap);
+      const pageIds = Object.keys(pageIdToRevisionIdMap);
 
-    if (pageIds.length === 0) {
-      return res.apiv3Err(new ErrorV3('Select pages to delete.', 'no_page_selected'), 400);
-    }
-    if (isAnyoneWithTheLink && pageIds.length !== 1) {
-      return res.apiv3Err(new ErrorV3('Only one restricted page can be selected', 'not_single_page'), 400);
-    }
-    if (pageIds.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
-      return res.apiv3Err(new ErrorV3(`The maximum number of pages you can select is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`, 'exceeded_maximum_number'), 400);
-    }
+      if (pageIds.length === 0) {
+        return res.apiv3Err(new ErrorV3('Select pages to delete.', 'no_page_selected'), 400);
+      }
+      if (isAnyoneWithTheLink && pageIds.length !== 1) {
+        return res.apiv3Err(new ErrorV3('Only one restricted page can be selected', 'not_single_page'), 400);
+      }
+      if (pageIds.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
+        return res.apiv3Err(new ErrorV3(`The maximum number of pages you can select is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`, 'exceeded_maximum_number'), 400);
+      }
 
-    let pagesToDelete;
-    try {
-      pagesToDelete = await Page.findByIdsAndViewer(pageIds, req.user, null, true, isAnyoneWithTheLink);
-    }
-    catch (err) {
-      logger.error('Failed to find pages to delete.', err);
-      return res.apiv3Err(new ErrorV3('Failed to find pages to delete.'));
-    }
-    if (isAnyoneWithTheLink && pagesToDelete[0].grant !== PageGrant.GRANT_RESTRICTED) {
-      return res.apiv3Err(new ErrorV3('The grant of the retrieved page is not restricted'), 500);
-    }
+      let pagesToDelete;
+      try {
+        pagesToDelete = await Page.findByIdsAndViewer(pageIds, req.user, null, true, isAnyoneWithTheLink);
+      }
+      catch (err) {
+        logger.error('Failed to find pages to delete.', err);
+        return res.apiv3Err(new ErrorV3('Failed to find pages to delete.'));
+      }
+      if (isAnyoneWithTheLink && pagesToDelete[0].grant !== PageGrant.GRANT_RESTRICTED) {
+        return res.apiv3Err(new ErrorV3('The grant of the retrieved page is not restricted'), 500);
+      }
 
-    let pagesCanBeDeleted;
-    if (isCompletely) {
-      pagesCanBeDeleted = await crowi.pageService.filterPagesByCanDeleteCompletely(pagesToDelete, req.user, isRecursively);
-    }
-    else {
-      const filteredPages = pagesToDelete.filter(p => p.isEmpty || p.isUpdatable(pageIdToRevisionIdMap[p._id].toString()));
-      pagesCanBeDeleted = await crowi.pageService.filterPagesByCanDelete(filteredPages, req.user, isRecursively);
-    }
+      let pagesCanBeDeleted;
+      if (isCompletely) {
+        pagesCanBeDeleted = await crowi.pageService.filterPagesByCanDeleteCompletely(pagesToDelete, req.user, isRecursively);
+      }
+      else {
+        const filteredPages = pagesToDelete.filter(p => p.isEmpty || p.isUpdatable(pageIdToRevisionIdMap[p._id].toString()));
+        pagesCanBeDeleted = await crowi.pageService.filterPagesByCanDelete(filteredPages, req.user, isRecursively);
+      }
 
-    if (pagesCanBeDeleted.length === 0) {
-      const msg = 'No pages can be deleted.';
-      return res.apiv3Err(new ErrorV3(msg), 500);
-    }
+      if (pagesCanBeDeleted.length === 0) {
+        const msg = 'No pages can be deleted.';
+        return res.apiv3Err(new ErrorV3(msg), 500);
+      }
 
-    // run delete
-    const activityParameters = {
-      ip: req.ip,
-      endpoint: req.originalUrl,
-    };
-    const options = { isCompletely, isRecursively };
-    crowi.pageService.deleteMultiplePages(pagesCanBeDeleted, req.user, options, activityParameters);
+      // run delete
+      const activityParameters = {
+        ip: req.ip,
+        endpoint: req.originalUrl,
+      };
+      const options = { isCompletely, isRecursively };
+      crowi.pageService.deleteMultiplePages(pagesCanBeDeleted, req.user, options, activityParameters);
 
-    return res.apiv3({ paths: pagesCanBeDeleted.map(p => p.path), isRecursively, isCompletely });
-  });
+      return res.apiv3({ paths: pagesCanBeDeleted.map(p => p.path), isRecursively, isCompletely });
+    },
+  );
 
   /**
    * @swagger
@@ -841,26 +890,35 @@ module.exports = (crowi) => {
    *                  description: Empty object
    */
   // eslint-disable-next-line max-len
-  router.post('/convert-pages-by-path', accessTokenParser(), loginRequiredStrictly, excludeReadOnlyUser, adminRequired, validator.convertPagesByPath, apiV3FormValidator, async(req, res) => {
-    const { convertPath } = req.body;
-
-    // Convert by path
-    const normalizedPath = normalizePath(convertPath);
-    try {
-      await crowi.pageService.normalizeParentByPath(normalizedPath, req.user);
-    }
-    catch (err) {
-      logger.error(err);
+  router.post(
+    '/convert-pages-by-path',
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]),
+    loginRequiredStrictly,
+    excludeReadOnlyUser,
+    adminRequired,
+    validator.convertPagesByPath,
+    apiV3FormValidator,
+    async(req, res) => {
+      const { convertPath } = req.body;
 
-      if (isV5ConversionError(err)) {
-        return res.apiv3Err(new ErrorV3(err.message, err.code), 400);
+      // Convert by path
+      const normalizedPath = normalizePath(convertPath);
+      try {
+        await crowi.pageService.normalizeParentByPath(normalizedPath, req.user);
       }
+      catch (err) {
+        logger.error(err);
 
-      return res.apiv3Err(new ErrorV3('Failed to convert pages.'), 400);
-    }
+        if (isV5ConversionError(err)) {
+          return res.apiv3Err(new ErrorV3(err.message, err.code), 400);
+        }
 
-    return res.apiv3({});
-  });
+        return res.apiv3Err(new ErrorV3('Failed to convert pages.'), 400);
+      }
+
+      return res.apiv3({});
+    },
+  );
 
   /**
    * @swagger
@@ -893,33 +951,41 @@ module.exports = (crowi) => {
    *                  description: Empty object
   */
   // eslint-disable-next-line max-len
-  router.post('/legacy-pages-migration', accessTokenParser(), loginRequired, excludeReadOnlyUser, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
-    const { pageIds: _pageIds, isRecursively } = req.body;
+  router.post(
+    '/legacy-pages-migration',
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]),
+    loginRequired,
+    excludeReadOnlyUser,
+    validator.legacyPagesMigration,
+    apiV3FormValidator,
+    async(req, res) => {
+      const { pageIds: _pageIds, isRecursively } = req.body;
 
-    // Convert by pageIds
-    const pageIds = _pageIds == null ? [] : _pageIds;
+      // Convert by pageIds
+      const pageIds = _pageIds == null ? [] : _pageIds;
 
-    if (pageIds.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
-      return res.apiv3Err(new ErrorV3(`The maximum number of pages you can select is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`, 'exceeded_maximum_number'), 400);
-    }
-    if (pageIds.length === 0) {
-      return res.apiv3Err(new ErrorV3('No page is selected.'), 400);
-    }
+      if (pageIds.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
+        return res.apiv3Err(new ErrorV3(`The maximum number of pages you can select is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`, 'exceeded_maximum_number'), 400);
+      }
+      if (pageIds.length === 0) {
+        return res.apiv3Err(new ErrorV3('No page is selected.'), 400);
+      }
 
-    try {
-      if (isRecursively) {
-        await crowi.pageService.normalizeParentByPageIdsRecursively(pageIds, req.user);
+      try {
+        if (isRecursively) {
+          await crowi.pageService.normalizeParentByPageIdsRecursively(pageIds, req.user);
+        }
+        else {
+          await crowi.pageService.normalizeParentByPageIds(pageIds, req.user);
+        }
       }
-      else {
-        await crowi.pageService.normalizeParentByPageIds(pageIds, req.user);
+      catch (err) {
+        return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);
       }
-    }
-    catch (err) {
-      return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);
-    }
 
-    return res.apiv3({});
-  });
+      return res.apiv3({});
+    },
+  );
 
   /**
    * @swagger
@@ -942,16 +1008,21 @@ module.exports = (crowi) => {
    *                      type: number
    *                      description: Number of pages that can be migrated
    */
-  router.get('/v5-migration-status', accessTokenParser(), loginRequired, async(req, res) => {
-    try {
-      const isV5Compatible = crowi.configManager.getConfig('app:isV5Compatible');
-      const migratablePagesCount = req.user != null ? await crowi.pageService.countPagesCanNormalizeParentByUser(req.user) : null; // null check since not using loginRequiredStrictly
-      return res.apiv3({ isV5Compatible, migratablePagesCount });
-    }
-    catch (err) {
-      return res.apiv3Err(new ErrorV3('Failed to obtain migration status'));
-    }
-  });
+  router.get(
+    '/v5-migration-status',
+    accessTokenParser([SCOPE.READ.FEATURES.PAGE]),
+    loginRequired,
+    async(req, res) => {
+      try {
+        const isV5Compatible = crowi.configManager.getConfig('app:isV5Compatible');
+        const migratablePagesCount = req.user != null ? await crowi.pageService.countPagesCanNormalizeParentByUser(req.user) : null; // null check since not using loginRequiredStrictly
+        return res.apiv3({ isV5Compatible, migratablePagesCount });
+      }
+      catch (err) {
+        return res.apiv3Err(new ErrorV3('Failed to obtain migration status'));
+      }
+    },
+  );
 
   return router;
 };

+ 1 - 1
apps/app/src/server/routes/apiv3/personal-setting/delete-access-token.ts

@@ -38,7 +38,7 @@ export const deleteAccessTokenHandlersFactory: DeleteAccessTokenHandlersFactory
   const activityEvent = crowi.event('activity');
 
   return [
-    accessTokenParser([SCOPE.WRITE.USER.API.ACCESS_TOKEN]),
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]),
     loginRequiredStrictly,
     addActivity,
     validator,

+ 1 - 1
apps/app/src/server/routes/apiv3/personal-setting/delete-all-access-tokens.ts

@@ -27,7 +27,7 @@ export const deleteAllAccessTokensHandlersFactory: DeleteAllAccessTokensHandlers
   const activityEvent = crowi.event('activity');
 
   return [
-    accessTokenParser([SCOPE.WRITE.USER.API.ACCESS_TOKEN]),
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]),
     loginRequiredStrictly,
     addActivity,
     async(req: DeleteAllAccessTokensRequest, res: ApiV3Response) => {

+ 1 - 1
apps/app/src/server/routes/apiv3/personal-setting/get-access-tokens.ts

@@ -25,7 +25,7 @@ export const getAccessTokenHandlerFactory: GetAccessTokenHandlerFactory = (crowi
   const addActivity = generateAddActivityMiddleware();
 
   return [
-    accessTokenParser([SCOPE.READ.USER.API.ACCESS_TOKEN]),
+    accessTokenParser([SCOPE.READ.USER_SETTINGS.API.ACCESS_TOKEN]),
     loginRequiredStrictly,
     addActivity,
     async(req: GetAccessTokenRequest, res: ApiV3Response) => {

+ 141 - 131
apps/app/src/server/routes/apiv3/personal-setting/index.js

@@ -5,6 +5,7 @@ import { body } from 'express-validator';
 import { i18n } from '^/config/next-i18next.config';
 
 import { SupportedAction } from '~/interfaces/activity';
+import { SCOPE } from '~/interfaces/scope';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import loggerFactory from '~/utils/logger';
 
@@ -153,7 +154,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: personal params
    */
-  router.get('/', accessTokenParser(), loginRequiredStrictly, async(req, res) => {
+  router.get('/', accessTokenParser([SCOPE.READ.USER_SETTINGS.INFO]), loginRequiredStrictly, async(req, res) => {
     const { username } = req.user;
     try {
       const user = await User.findUserByUsername(username);
@@ -195,7 +196,7 @@ module.exports = (crowi) => {
    *                      type: number
    *                      description: Minimum password length
    */
-  router.get('/is-password-set', accessTokenParser(), loginRequiredStrictly, async(req, res) => {
+  router.get('/is-password-set', accessTokenParser([SCOPE.READ.USER_SETTINGS.PASSWORD]), loginRequiredStrictly, async(req, res) => {
     const { username } = req.user;
 
     try {
@@ -237,36 +238,37 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: personal params
    */
-  router.put('/', accessTokenParser(), loginRequiredStrictly, addActivity, validator.personal, apiV3FormValidator, async(req, res) => {
+  router.put('/', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.INFO]), loginRequiredStrictly, addActivity, validator.personal, apiV3FormValidator,
+    async(req, res) => {
 
-    try {
-      const user = await User.findOne({ _id: req.user.id });
-      user.name = req.body.name;
-      user.email = req.body.email;
-      user.lang = req.body.lang;
-      user.isEmailPublished = req.body.isEmailPublished;
-      user.slackMemberId = req.body.slackMemberId;
-
-      const isUniqueEmail = await user.isUniqueEmail();
-
-      if (!isUniqueEmail) {
-        logger.error('email-is-not-unique');
-        return res.apiv3Err(new ErrorV3('The email is already in use', 'email-is-already-in-use'));
-      }
+      try {
+        const user = await User.findOne({ _id: req.user.id });
+        user.name = req.body.name;
+        user.email = req.body.email;
+        user.lang = req.body.lang;
+        user.isEmailPublished = req.body.isEmailPublished;
+        user.slackMemberId = req.body.slackMemberId;
 
-      const updatedUser = await user.save();
+        const isUniqueEmail = await user.isUniqueEmail();
 
-      const parameters = { action: SupportedAction.ACTION_USER_PERSONAL_SETTINGS_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
+        if (!isUniqueEmail) {
+          logger.error('email-is-not-unique');
+          return res.apiv3Err(new ErrorV3('The email is already in use', 'email-is-already-in-use'));
+        }
 
-      return res.apiv3({ updatedUser });
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err('update-personal-settings-failed');
-    }
+        const updatedUser = await user.save();
 
-  });
+        const parameters = { action: SupportedAction.ACTION_USER_PERSONAL_SETTINGS_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3({ updatedUser });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err('update-personal-settings-failed');
+      }
+
+    });
 
   /**
    * @swagger
@@ -295,22 +297,24 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: user data
    */
-  router.put('/image-type', accessTokenParser(), loginRequiredStrictly, addActivity, validator.imageType, apiV3FormValidator, async(req, res) => {
-    const { isGravatarEnabled } = req.body;
+  router.put('/image-type', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.INFO]), loginRequiredStrictly, addActivity,
+    validator.imageType, apiV3FormValidator,
+    async(req, res) => {
+      const { isGravatarEnabled } = req.body;
 
-    try {
-      const userData = await req.user.updateIsGravatarEnabled(isGravatarEnabled);
+      try {
+        const userData = await req.user.updateIsGravatarEnabled(isGravatarEnabled);
 
-      const parameters = { action: SupportedAction.ACTION_USER_IMAGE_TYPE_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
+        const parameters = { action: SupportedAction.ACTION_USER_IMAGE_TYPE_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
 
-      return res.apiv3({ userData });
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err('update-personal-settings-failed');
-    }
-  });
+        return res.apiv3({ userData });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err('update-personal-settings-failed');
+      }
+    });
 
   /**
    * @swagger
@@ -332,7 +336,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: array of external accounts
    */
-  router.get('/external-accounts', accessTokenParser(), loginRequiredStrictly, async(req, res) => {
+  router.get('/external-accounts', accessTokenParser([SCOPE.READ.USER_SETTINGS.EXTERNAL_ACCOUNT]), loginRequiredStrictly, async(req, res) => {
     const userData = req.user;
 
     try {
@@ -375,27 +379,28 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: user data updated
    */
-  router.put('/password', accessTokenParser(), loginRequiredStrictly, addActivity, validator.password, apiV3FormValidator, async(req, res) => {
-    const { body, user } = req;
-    const { oldPassword, newPassword } = body;
+  router.put('/password', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.PASSWORD]), loginRequiredStrictly, addActivity, validator.password, apiV3FormValidator,
+    async(req, res) => {
+      const { body, user } = req;
+      const { oldPassword, newPassword } = body;
 
-    if (user.isPasswordSet() && !user.isPasswordValid(oldPassword)) {
-      return res.apiv3Err('wrong-current-password', 400);
-    }
-    try {
-      const userData = await user.updatePassword(newPassword);
+      if (user.isPasswordSet() && !user.isPasswordValid(oldPassword)) {
+        return res.apiv3Err('wrong-current-password', 400);
+      }
+      try {
+        const userData = await user.updatePassword(newPassword);
 
-      const parameters = { action: SupportedAction.ACTION_USER_PASSWORD_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
+        const parameters = { action: SupportedAction.ACTION_USER_PASSWORD_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
 
-      return res.apiv3({ userData });
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err('update-password-failed');
-    }
+        return res.apiv3({ userData });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err('update-password-failed');
+      }
 
-  });
+    });
 
   /**
    * @swagger
@@ -419,7 +424,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: user data
    */
-  router.put('/api-token', loginRequiredStrictly, addActivity, async(req, res) => {
+  router.put('/api-token', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.API_TOKEN]), loginRequiredStrictly, addActivity, async(req, res) => {
     const { user } = req;
 
     try {
@@ -456,7 +461,7 @@ module.exports = (crowi) => {
    *                   type: objet
    *                   description: array of access tokens
    */
-  router.get('/access-token', getAccessTokenHandlerFactory(crowi));
+  router.get('/access-token', accessTokenParser([SCOPE.READ.USER_SETTINGS.API.ACCESS_TOKEN]), getAccessTokenHandlerFactory(crowi));
 
   /**
    * @swagger
@@ -489,7 +494,7 @@ module.exports = (crowi) => {
    *                     type: string[]
    *                     description: scope of access token
    */
-  router.post('/access-token', generateAccessTokenHandlerFactory(crowi));
+  router.post('/access-token', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]), generateAccessTokenHandlerFactory(crowi));
 
   /**
    * @swagger
@@ -504,7 +509,7 @@ module.exports = (crowi) => {
    *         description: succeded to delete access token
    *
    */
-  router.delete('/access-token', deleteAccessTokenHandlersFactory(crowi));
+  router.delete('/access-token', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]), deleteAccessTokenHandlersFactory(crowi));
 
   /**
    * @swagger
@@ -518,7 +523,7 @@ module.exports = (crowi) => {
    *         200:
    *           description: succeded to delete all access tokens
    */
-  router.delete('/access-token/all', deleteAllAccessTokensHandlersFactory(crowi));
+  router.delete('/access-token/all', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]), deleteAllAccessTokensHandlersFactory(crowi));
 
   /**
    * @swagger
@@ -548,31 +553,33 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: Ldap account associate to me
    */
-  router.put('/associate-ldap', accessTokenParser(), loginRequiredStrictly, addActivity, validator.associateLdap, apiV3FormValidator, async(req, res) => {
-    const { passportService } = crowi;
-    const { user, body } = req;
-    const { username } = body;
-
-    if (!passportService.isLdapStrategySetup) {
-      logger.error('LdapStrategy has not been set up');
-      return res.apiv3Err('associate-ldap-account-failed', 405);
-    }
+  router.put('/associate-ldap', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.EXTERNAL_ACCOUNT]), loginRequiredStrictly, addActivity,
+    validator.associateLdap, apiV3FormValidator,
+    async(req, res) => {
+      const { passportService } = crowi;
+      const { user, body } = req;
+      const { username } = body;
+
+      if (!passportService.isLdapStrategySetup) {
+        logger.error('LdapStrategy has not been set up');
+        return res.apiv3Err('associate-ldap-account-failed', 405);
+      }
 
-    try {
-      await passport.authenticate('ldapauth');
-      const associateUser = await ExternalAccount.associate('ldap', username, user);
+      try {
+        await passport.authenticate('ldapauth');
+        const associateUser = await ExternalAccount.associate('ldap', username, user);
 
-      const parameters = { action: SupportedAction.ACTION_USER_LDAP_ACCOUNT_ASSOCIATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
+        const parameters = { action: SupportedAction.ACTION_USER_LDAP_ACCOUNT_ASSOCIATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
 
-      return res.apiv3({ associateUser });
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err('associate-ldap-account-failed');
-    }
+        return res.apiv3({ associateUser });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err('associate-ldap-account-failed');
+      }
 
-  });
+    });
 
   /**
    * @swagger
@@ -601,29 +608,30 @@ module.exports = (crowi) => {
    *                      description: Ldap account disassociate to me
    */
   // eslint-disable-next-line max-len
-  router.put('/disassociate-ldap', accessTokenParser(), loginRequiredStrictly, addActivity, validator.disassociateLdap, apiV3FormValidator, async(req, res) => {
-    const { user, body } = req;
-    const { providerType, accountId } = body;
-
-    try {
-      const count = await ExternalAccount.count({ user });
-      // make sure password set or this user has two or more ExternalAccounts
-      if (user.password == null && count <= 1) {
+  router.put('/disassociate-ldap', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.EXTERNAL_ACCOUNT]), loginRequiredStrictly, addActivity, validator.disassociateLdap, apiV3FormValidator,
+    async(req, res) => {
+      const { user, body } = req;
+      const { providerType, accountId } = body;
+
+      try {
+        const count = await ExternalAccount.count({ user });
+        // make sure password set or this user has two or more ExternalAccounts
+        if (user.password == null && count <= 1) {
+          return res.apiv3Err('disassociate-ldap-account-failed');
+        }
+        const disassociateUser = await ExternalAccount.findOneAndRemove({ providerType, accountId, user });
+
+        const parameters = { action: SupportedAction.ACTION_USER_LDAP_ACCOUNT_DISCONNECT };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3({ disassociateUser });
+      }
+      catch (err) {
+        logger.error(err);
         return res.apiv3Err('disassociate-ldap-account-failed');
       }
-      const disassociateUser = await ExternalAccount.findOneAndRemove({ providerType, accountId, user });
 
-      const parameters = { action: SupportedAction.ACTION_USER_LDAP_ACCOUNT_DISCONNECT };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-
-      return res.apiv3({ disassociateUser });
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err('disassociate-ldap-account-failed');
-    }
-
-  });
+    });
 
   /**
    * @swagger
@@ -657,34 +665,36 @@ module.exports = (crowi) => {
    *                  type: object
    *                  description: editor settings
    */
-  router.put('/editor-settings', accessTokenParser(), loginRequiredStrictly, addActivity, validator.editorSettings, apiV3FormValidator, async(req, res) => {
-    const query = { userId: req.user.id };
-    const { body } = req;
+  router.put('/editor-settings', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.OTHER]), loginRequiredStrictly,
+    addActivity, validator.editorSettings, apiV3FormValidator,
+    async(req, res) => {
+      const query = { userId: req.user.id };
+      const { body } = req;
 
-    const {
-      theme, keymapMode, styleActiveLine, autoFormatMarkdownTable,
-    } = body;
+      const {
+        theme, keymapMode, styleActiveLine, autoFormatMarkdownTable,
+      } = body;
 
-    const document = {
-      theme, keymapMode, styleActiveLine, autoFormatMarkdownTable,
-    };
+      const document = {
+        theme, keymapMode, styleActiveLine, autoFormatMarkdownTable,
+      };
 
-    // Insert if document does not exist, and return new values
-    // See: https://mongoosejs.com/docs/api.html#model_Model.findOneAndUpdate
-    const options = { upsert: true, new: true };
-    try {
-      const response = await EditorSettings.findOneAndUpdate(query, { $set: document }, options);
+      // Insert if document does not exist, and return new values
+      // See: https://mongoosejs.com/docs/api.html#model_Model.findOneAndUpdate
+      const options = { upsert: true, new: true };
+      try {
+        const response = await EditorSettings.findOneAndUpdate(query, { $set: document }, options);
 
-      const parameters = { action: SupportedAction.ACTION_USER_EDITOR_SETTINGS_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
+        const parameters = { action: SupportedAction.ACTION_USER_EDITOR_SETTINGS_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
 
-      return res.apiv3(response);
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err('updating-editor-settings-failed');
-    }
-  });
+        return res.apiv3(response);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err('updating-editor-settings-failed');
+      }
+    });
 
 
   /**
@@ -705,7 +715,7 @@ module.exports = (crowi) => {
    *                  type: object
    *                  description: editor settings
    */
-  router.get('/editor-settings', accessTokenParser(), loginRequiredStrictly, async(req, res) => {
+  router.get('/editor-settings', accessTokenParser([SCOPE.READ.USER_SETTINGS.OTHER]), loginRequiredStrictly, async(req, res) => {
     try {
       const query = { userId: req.user.id };
       const editorSettings = await EditorSettings.findOne(query) ?? new EditorSettings();
@@ -750,7 +760,7 @@ module.exports = (crowi) => {
    *                 type: object
    */
   // eslint-disable-next-line max-len
-  router.put('/in-app-notification-settings', accessTokenParser(), loginRequiredStrictly, addActivity, validator.inAppNotificationSettings, apiV3FormValidator, async(req, res) => {
+  router.put('/in-app-notification-settings', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.IN_APP_NOTIFICATION]), loginRequiredStrictly, addActivity, validator.inAppNotificationSettings, apiV3FormValidator, async(req, res) => {
     const query = { userId: req.user.id };
     const subscribeRules = req.body.subscribeRules;
 
@@ -793,7 +803,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: InAppNotificationSettings
    */
-  router.get('/in-app-notification-settings', accessTokenParser(), loginRequiredStrictly, async(req, res) => {
+  router.get('/in-app-notification-settings', accessTokenParser([SCOPE.READ.USER_SETTINGS.IN_APP_NOTIFICATION]), loginRequiredStrictly, async(req, res) => {
     const query = { userId: req.user.id };
     try {
       const response = await InAppNotificationSettings.findOne(query);
@@ -834,7 +844,7 @@ module.exports = (crowi) => {
    *                     type: boolean
    */
   // eslint-disable-next-line max-len
-  router.put('/questionnaire-settings', accessTokenParser(), loginRequiredStrictly, validator.questionnaireSettings, apiV3FormValidator, async(req, res) => {
+  router.put('/questionnaire-settings', accessTokenParser([SCOPE.WRITE.FEATURES.QUESTIONNAIRE]), loginRequiredStrictly, validator.questionnaireSettings, apiV3FormValidator, async(req, res) => {
     const { isQuestionnaireEnabled } = req.body;
     const { user } = req;
     try {

+ 85 - 82
apps/app/src/server/routes/apiv3/revisions.js

@@ -3,6 +3,7 @@ import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import express from 'express';
 import { connection } from 'mongoose';
 
+import { SCOPE } from '~/interfaces/scope';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { Revision } from '~/server/models/revision';
 import { normalizeLatestRevisionIfBroken } from '~/server/service/revision/normalize-latest-revision-if-broken';
@@ -134,74 +135,75 @@ module.exports = (crowi) => {
    *                    type: number
    *                    description: offset of the revisions
    */
-  router.get('/list', certifySharedPage, accessTokenParser(), loginRequired, validator.retrieveRevisions, apiV3FormValidator, async(req, res) => {
-    const pageId = req.query.pageId;
-    const limit = req.query.limit || await crowi.configManager.getConfig('customize:showPageLimitationS') || 10;
-    const { isSharedPage } = req;
-    const offset = req.query.offset || 0;
-
-    // check whether accessible
-    if (!isSharedPage && !(await Page.isAccessiblePageByViewer(pageId, req.user))) {
-      return res.apiv3Err(new ErrorV3('Current user is not accessible to this page.', 'forbidden-page'), 403);
-    }
-
-    // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
-    try {
-      await normalizeLatestRevisionIfBroken(pageId);
-    }
-    catch (err) {
-      logger.error('Error occurred in normalizing the latest revision');
-    }
-
-    try {
-      const page = await Page.findOne({ _id: pageId });
-
-      const appliedAt = await getAppliedAtOfTheMigrationFile();
-
-      const queryOpts = {
-        offset,
-        sort: { createdAt: -1 },
-        populate: 'author',
-        pagination: false,
-      };
+  router.get('/list', certifySharedPage, accessTokenParser(SCOPE.READ.FEATURES.PAGE), loginRequired, validator.retrieveRevisions, apiV3FormValidator,
+    async(req, res) => {
+      const pageId = req.query.pageId;
+      const limit = req.query.limit || await crowi.configManager.getConfig('customize:showPageLimitationS') || 10;
+      const { isSharedPage } = req;
+      const offset = req.query.offset || 0;
+
+      // check whether accessible
+      if (!isSharedPage && !(await Page.isAccessiblePageByViewer(pageId, req.user))) {
+        return res.apiv3Err(new ErrorV3('Current user is not accessible to this page.', 'forbidden-page'), 403);
+      }
 
-      if (limit > 0) {
-        queryOpts.limit = limit;
-        queryOpts.pagination = true;
+      // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
+      try {
+        await normalizeLatestRevisionIfBroken(pageId);
+      }
+      catch (err) {
+        logger.error('Error occurred in normalizing the latest revision');
       }
 
-      const queryCondition = {
-        pageId: page._id,
-        createdAt: { $gt: appliedAt },
-      };
+      try {
+        const page = await Page.findOne({ _id: pageId });
 
-      // https://redmine.weseek.co.jp/issues/151652
-      const paginateResult = await Revision.paginate(
-        queryCondition,
-        queryOpts,
-      );
+        const appliedAt = await getAppliedAtOfTheMigrationFile();
 
-      paginateResult.docs.forEach((doc) => {
-        if (doc.author != null && doc.author instanceof User) {
-          doc.author = serializeUserSecurely(doc.author);
-        }
-      });
+        const queryOpts = {
+          offset,
+          sort: { createdAt: -1 },
+          populate: 'author',
+          pagination: false,
+        };
 
-      const result = {
-        revisions: paginateResult.docs,
-        totalCount: paginateResult.totalDocs,
-        offset: paginateResult.offset,
-      };
+        if (limit > 0) {
+          queryOpts.limit = limit;
+          queryOpts.pagination = true;
+        }
 
-      return res.apiv3(result);
-    }
-    catch (err) {
-      const msg = 'Error occurred in getting revisions by poge id';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'faild-to-find-revisions'), 500);
-    }
+        const queryCondition = {
+          pageId: page._id,
+          createdAt: { $gt: appliedAt },
+        };
+
+        // https://redmine.weseek.co.jp/issues/151652
+        const paginateResult = await Revision.paginate(
+          queryCondition,
+          queryOpts,
+        );
+
+        paginateResult.docs.forEach((doc) => {
+          if (doc.author != null && doc.author instanceof User) {
+            doc.author = serializeUserSecurely(doc.author);
+          }
+        });
+
+        const result = {
+          revisions: paginateResult.docs,
+          totalCount: paginateResult.totalDocs,
+          offset: paginateResult.offset,
+        };
+
+        return res.apiv3(result);
+      }
+      catch (err) {
+        const msg = 'Error occurred in getting revisions by poge id';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'faild-to-find-revisions'), 500);
+      }
 
-  });
+    });
 
   /**
    * @swagger
@@ -233,32 +235,33 @@ module.exports = (crowi) => {
    *                    revision:
    *                      $ref: '#/components/schemas/Revision'
    */
-  router.get('/:id', certifySharedPage, accessTokenParser(), loginRequired, validator.retrieveRevisionById, apiV3FormValidator, async(req, res) => {
-    const revisionId = req.params.id;
-    const pageId = req.query.pageId;
-    const { isSharedPage } = req;
-
-    // check whether accessible
-    if (!isSharedPage && !(await Page.isAccessiblePageByViewer(pageId, req.user))) {
-      return res.apiv3Err(new ErrorV3('Current user is not accessible to this page.', 'forbidden-page'), 403);
-    }
+  router.get('/:id', certifySharedPage, accessTokenParser(SCOPE.READ.FEATURES.PAGE), loginRequired, validator.retrieveRevisionById, apiV3FormValidator,
+    async(req, res) => {
+      const revisionId = req.params.id;
+      const pageId = req.query.pageId;
+      const { isSharedPage } = req;
+
+      // check whether accessible
+      if (!isSharedPage && !(await Page.isAccessiblePageByViewer(pageId, req.user))) {
+        return res.apiv3Err(new ErrorV3('Current user is not accessible to this page.', 'forbidden-page'), 403);
+      }
 
-    try {
-      const revision = await Revision.findById(revisionId).populate('author');
+      try {
+        const revision = await Revision.findById(revisionId).populate('author');
 
-      if (revision.author != null && revision.author instanceof User) {
-        revision.author = serializeUserSecurely(revision.author);
-      }
+        if (revision.author != null && revision.author instanceof User) {
+          revision.author = serializeUserSecurely(revision.author);
+        }
 
-      return res.apiv3({ revision });
-    }
-    catch (err) {
-      const msg = 'Error occurred in getting revision data by id';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'faild-to-find-revision'), 500);
-    }
+        return res.apiv3({ revision });
+      }
+      catch (err) {
+        const msg = 'Error occurred in getting revision data by id';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'faild-to-find-revision'), 500);
+      }
 
-  });
+    });
 
   return router;
 };

+ 31 - 29
apps/app/src/server/routes/apiv3/search.js

@@ -1,13 +1,13 @@
 import { ErrorV3 } from '@growi/core/dist/models';
 
 import { SupportedAction } from '~/interfaces/activity';
+import { SCOPE } from '~/interfaces/scope';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
-
 const logger = loggerFactory('growi:routes:apiv3:search'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
@@ -126,7 +126,7 @@ module.exports = (crowi) => {
    *                    description: Status of indices
    *                    $ref: '#/components/schemas/Indices'
    */
-  router.get('/indices', noCache(), accessTokenParser(), loginRequired, adminRequired, async(req, res) => {
+  router.get('/indices', noCache(), accessTokenParser([SCOPE.READ.ADMIN.FULL_TEXT_SEARCH]), loginRequired, adminRequired, async(req, res) => {
     const { searchService } = crowi;
 
     if (!searchService.isConfigured) {
@@ -154,7 +154,7 @@ module.exports = (crowi) => {
    *        200:
    *          description: Successfully connected
    */
-  router.post('/connection', accessTokenParser(), loginRequired, adminRequired, addActivity, async(req, res) => {
+  router.post('/connection', accessTokenParser([SCOPE.WRITE.ADMIN.FULL_TEXT_SEARCH]), loginRequired, adminRequired, addActivity, async(req, res) => {
     const { searchService } = crowi;
 
     if (!searchService.isConfigured) {
@@ -208,42 +208,44 @@ module.exports = (crowi) => {
    *                    type: string
    *                    description: Operation is successfully processed, or requested
    */
-  router.put('/indices', accessTokenParser(), loginRequired, adminRequired, addActivity, validatorForPutIndices, apiV3FormValidator, async(req, res) => {
-    const operation = req.body.operation;
+  router.put('/indices', accessTokenParser([SCOPE.WRITE.ADMIN.FULL_TEXT_SEARCH]), loginRequired, adminRequired, addActivity,
+    validatorForPutIndices, apiV3FormValidator,
+    async(req, res) => {
+      const operation = req.body.operation;
 
-    const { searchService } = crowi;
+      const { searchService } = crowi;
 
-    if (!searchService.isConfigured) {
-      return res.apiv3Err(new ErrorV3('SearchService is not configured', 'search-service-unconfigured'));
-    }
-    if (!searchService.isReachable) {
-      return res.apiv3Err(new ErrorV3('SearchService is not reachable', 'search-service-unreachable'));
-    }
+      if (!searchService.isConfigured) {
+        return res.apiv3Err(new ErrorV3('SearchService is not configured', 'search-service-unconfigured'));
+      }
+      if (!searchService.isReachable) {
+        return res.apiv3Err(new ErrorV3('SearchService is not reachable', 'search-service-unreachable'));
+      }
 
-    try {
-      switch (operation) {
-        case 'normalize':
+      try {
+        switch (operation) {
+          case 'normalize':
           // wait the processing is terminated
-          await searchService.normalizeIndices();
+            await searchService.normalizeIndices();
 
-          activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SEARCH_INDICES_NORMALIZE });
+            activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SEARCH_INDICES_NORMALIZE });
 
-          return res.status(200).send({ message: 'Operation is successfully processed.' });
-        case 'rebuild':
+            return res.status(200).send({ message: 'Operation is successfully processed.' });
+          case 'rebuild':
           // NOT wait the processing is terminated
-          searchService.rebuildIndex();
+            searchService.rebuildIndex();
 
-          activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SEARCH_INDICES_REBUILD });
+            activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SEARCH_INDICES_REBUILD });
 
-          return res.status(200).send({ message: 'Operation is successfully requested.' });
-        default:
-          throw new Error(`Unimplemented operation: ${operation}`);
+            return res.status(200).send({ message: 'Operation is successfully requested.' });
+          default:
+            throw new Error(`Unimplemented operation: ${operation}`);
+        }
       }
-    }
-    catch (err) {
-      return res.apiv3Err(err, 503);
-    }
-  });
+      catch (err) {
+        return res.apiv3Err(err, 503);
+      }
+    });
 
   return router;
 };

+ 334 - 315
apps/app/src/server/routes/apiv3/security-settings/index.js

@@ -2,8 +2,11 @@ import { ConfigSource } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import xss from 'xss';
 
+
 import { SupportedAction } from '~/interfaces/activity';
 import { PageDeleteConfigValue } from '~/interfaces/page-delete-config';
+import { SCOPE } from '~/interfaces/scope';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import ShareLink from '~/server/models/share-link';
@@ -454,7 +457,7 @@ module.exports = (crowi) => {
    *                        githubOAuth:
    *                          $ref: '#/components/schemas/GitHubOAuth
    */
-  router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, async(req, res) => {
 
     const securityParams = {
       generalSetting: {
@@ -592,7 +595,7 @@ module.exports = (crowi) => {
    *                  description: updated param
    */
   // eslint-disable-next-line max-len
-  router.put('/authentication/enabled', loginRequiredStrictly, adminRequired, addActivity, validator.authenticationSetting, apiV3FormValidator, async(req, res) => {
+  router.put('/authentication/enabled', accessTokenParser([SCOPE.WRITE.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, addActivity, validator.authenticationSetting, apiV3FormValidator, async(req, res) => {
     const { isEnabled, authId } = req.body;
 
     let setupStrategies = await crowi.passportService.getSetupStrategies();
@@ -704,7 +707,7 @@ module.exports = (crowi) => {
    *                        description: setup strategie
    *                      example: ["local"]
    */
-  router.get('/authentication/', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.get('/authentication/', accessTokenParser([SCOPE.READ.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, async(req, res) => {
     const setupStrategies = await crowi.passportService.getSetupStrategies();
 
     return res.apiv3({ setupStrategies });
@@ -734,70 +737,72 @@ module.exports = (crowi) => {
    *                schema:
    *                  $ref: '#/components/schemas/GeneralSetting'
    */
-  router.put('/general-setting', loginRequiredStrictly, adminRequired, addActivity, validator.generalSetting, apiV3FormValidator, async(req, res) => {
-    const updateData = {
-      'security:sessionMaxAge': parseInt(req.body.sessionMaxAge),
-      'security:restrictGuestMode': req.body.restrictGuestMode,
-      'security:pageDeletionAuthority': req.body.pageDeletionAuthority,
-      'security:pageRecursiveDeletionAuthority': req.body.pageRecursiveDeletionAuthority,
-      'security:pageCompleteDeletionAuthority': req.body.pageCompleteDeletionAuthority,
-      'security:pageRecursiveCompleteDeletionAuthority': req.body.pageRecursiveCompleteDeletionAuthority,
-      'security:isAllGroupMembershipRequiredForPageCompleteDeletion': req.body.isAllGroupMembershipRequiredForPageCompleteDeletion,
-      'security:list-policy:hideRestrictedByOwner': req.body.hideRestrictedByOwner,
-      'security:list-policy:hideRestrictedByGroup': req.body.hideRestrictedByGroup,
-      'security:user-homepage-deletion:isEnabled': req.body.isUsersHomepageDeletionEnabled,
-      // Validate user-homepage-deletion config
-      'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion': req.body.isUsersHomepageDeletionEnabled
-        ? req.body.isForceDeleteUserHomepageOnUserDeletion
-        : false,
-      'security:isRomUserAllowedToComment': req.body.isRomUserAllowedToComment,
-    };
+  router.put('/general-setting', accessTokenParser([SCOPE.WRITE.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, addActivity,
+    validator.generalSetting, apiV3FormValidator,
+    async(req, res) => {
+      const updateData = {
+        'security:sessionMaxAge': parseInt(req.body.sessionMaxAge),
+        'security:restrictGuestMode': req.body.restrictGuestMode,
+        'security:pageDeletionAuthority': req.body.pageDeletionAuthority,
+        'security:pageRecursiveDeletionAuthority': req.body.pageRecursiveDeletionAuthority,
+        'security:pageCompleteDeletionAuthority': req.body.pageCompleteDeletionAuthority,
+        'security:pageRecursiveCompleteDeletionAuthority': req.body.pageRecursiveCompleteDeletionAuthority,
+        'security:isAllGroupMembershipRequiredForPageCompleteDeletion': req.body.isAllGroupMembershipRequiredForPageCompleteDeletion,
+        'security:list-policy:hideRestrictedByOwner': req.body.hideRestrictedByOwner,
+        'security:list-policy:hideRestrictedByGroup': req.body.hideRestrictedByGroup,
+        'security:user-homepage-deletion:isEnabled': req.body.isUsersHomepageDeletionEnabled,
+        // Validate user-homepage-deletion config
+        'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion': req.body.isUsersHomepageDeletionEnabled
+          ? req.body.isForceDeleteUserHomepageOnUserDeletion
+          : false,
+        'security:isRomUserAllowedToComment': req.body.isRomUserAllowedToComment,
+      };
 
-    // Validate delete config
-    const [singleAuthority1, recursiveAuthority1] = prepareDeleteConfigValuesForCalc(req.body.pageDeletionAuthority, req.body.pageRecursiveDeletionAuthority);
-    // eslint-disable-next-line max-len
-    const [singleAuthority2, recursiveAuthority2] = prepareDeleteConfigValuesForCalc(req.body.pageCompleteDeletionAuthority, req.body.pageRecursiveCompleteDeletionAuthority);
-    const isDeleteConfigNormalized = validateDeleteConfigs(singleAuthority1, recursiveAuthority1)
+      // Validate delete config
+      const [singleAuthority1, recursiveAuthority1] = prepareDeleteConfigValuesForCalc(req.body.pageDeletionAuthority, req.body.pageRecursiveDeletionAuthority);
+      // eslint-disable-next-line max-len
+      const [singleAuthority2, recursiveAuthority2] = prepareDeleteConfigValuesForCalc(req.body.pageCompleteDeletionAuthority, req.body.pageRecursiveCompleteDeletionAuthority);
+      const isDeleteConfigNormalized = validateDeleteConfigs(singleAuthority1, recursiveAuthority1)
       && validateDeleteConfigs(singleAuthority2, recursiveAuthority2);
-    if (!isDeleteConfigNormalized) {
-      return res.apiv3Err(new ErrorV3('Delete config values are not correct.', 'delete_config_not_normalized'));
-    }
+      if (!isDeleteConfigNormalized) {
+        return res.apiv3Err(new ErrorV3('Delete config values are not correct.', 'delete_config_not_normalized'));
+      }
 
-    const wikiMode = await configManager.getConfig('security:wikiMode');
-    if (wikiMode === 'private' || wikiMode === 'public') {
-      logger.debug('security:restrictGuestMode will not be changed because wiki mode is forced to set');
-      delete updateData['security:restrictGuestMode'];
-    }
-    try {
-      await configManager.updateConfigs(updateData);
-      const securitySettingParams = {
-        sessionMaxAge: await configManager.getConfig('security:sessionMaxAge'),
-        restrictGuestMode: await configManager.getConfig('security:restrictGuestMode'),
-        pageDeletionAuthority: await configManager.getConfig('security:pageDeletionAuthority'),
-        pageCompleteDeletionAuthority: await configManager.getConfig('security:pageCompleteDeletionAuthority'),
-        pageRecursiveDeletionAuthority: await configManager.getConfig('security:pageRecursiveDeletionAuthority'),
-        pageRecursiveCompleteDeletionAuthority: await configManager.getConfig('security:pageRecursiveCompleteDeletionAuthority'),
-        isAllGroupMembershipRequiredForPageCompleteDeletion:
+      const wikiMode = await configManager.getConfig('security:wikiMode');
+      if (wikiMode === 'private' || wikiMode === 'public') {
+        logger.debug('security:restrictGuestMode will not be changed because wiki mode is forced to set');
+        delete updateData['security:restrictGuestMode'];
+      }
+      try {
+        await configManager.updateConfigs(updateData);
+        const securitySettingParams = {
+          sessionMaxAge: await configManager.getConfig('security:sessionMaxAge'),
+          restrictGuestMode: await configManager.getConfig('security:restrictGuestMode'),
+          pageDeletionAuthority: await configManager.getConfig('security:pageDeletionAuthority'),
+          pageCompleteDeletionAuthority: await configManager.getConfig('security:pageCompleteDeletionAuthority'),
+          pageRecursiveDeletionAuthority: await configManager.getConfig('security:pageRecursiveDeletionAuthority'),
+          pageRecursiveCompleteDeletionAuthority: await configManager.getConfig('security:pageRecursiveCompleteDeletionAuthority'),
+          isAllGroupMembershipRequiredForPageCompleteDeletion:
         await configManager.getConfig('security:isAllGroupMembershipRequiredForPageCompleteDeletion'),
-        hideRestrictedByOwner: await configManager.getConfig('security:list-policy:hideRestrictedByOwner'),
-        hideRestrictedByGroup: await configManager.getConfig('security:list-policy:hideRestrictedByGroup'),
-        isUsersHomepageDeletionEnabled: await configManager.getConfig('security:user-homepage-deletion:isEnabled'),
-        isForceDeleteUserHomepageOnUserDeletion:
+          hideRestrictedByOwner: await configManager.getConfig('security:list-policy:hideRestrictedByOwner'),
+          hideRestrictedByGroup: await configManager.getConfig('security:list-policy:hideRestrictedByGroup'),
+          isUsersHomepageDeletionEnabled: await configManager.getConfig('security:user-homepage-deletion:isEnabled'),
+          isForceDeleteUserHomepageOnUserDeletion:
         await configManager.getConfig('security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion'),
-        isRomUserAllowedToComment: await configManager.getConfig('security:isRomUserAllowedToComment'),
-      };
+          isRomUserAllowedToComment: await configManager.getConfig('security:isRomUserAllowedToComment'),
+        };
 
-      const parameters = { action: SupportedAction.ACTION_ADMIN_SECURITY_SETTINGS_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
+        const parameters = { action: SupportedAction.ACTION_ADMIN_SECURITY_SETTINGS_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
 
-      return res.apiv3({ securitySettingParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating security setting';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-secuirty-setting failed'));
-    }
-  });
+        return res.apiv3({ securitySettingParams });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating security setting';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-secuirty-setting failed'));
+      }
+    });
 
   /**
    * @swagger
@@ -825,26 +830,28 @@ module.exports = (crowi) => {
    *                    securitySettingParams:
    *                      $ref: '#/components/schemas/ShareLinkSetting'
    */
-  router.put('/share-link-setting', loginRequiredStrictly, adminRequired, addActivity, validator.generalSetting, apiV3FormValidator, async(req, res) => {
-    const updateData = {
-      'security:disableLinkSharing': req.body.disableLinkSharing,
-    };
-    try {
-      await configManager.updateConfigs(updateData);
-      const securitySettingParams = {
-        disableLinkSharing: configManager.getConfig('security:disableLinkSharing'),
+  router.put('/share-link-setting', accessTokenParser([SCOPE.WRITE.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, addActivity,
+    validator.generalSetting, apiV3FormValidator,
+    async(req, res) => {
+      const updateData = {
+        'security:disableLinkSharing': req.body.disableLinkSharing,
       };
-      // eslint-disable-next-line max-len
-      const parameters = { action: updateData['security:disableLinkSharing'] ? SupportedAction.ACTION_ADMIN_REJECT_SHARE_LINK : SupportedAction.ACTION_ADMIN_PERMIT_SHARE_LINK };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-      return res.apiv3({ securitySettingParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating security setting';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-secuirty-setting failed'));
-    }
-  });
+      try {
+        await configManager.updateConfigs(updateData);
+        const securitySettingParams = {
+          disableLinkSharing: configManager.getConfig('security:disableLinkSharing'),
+        };
+        // eslint-disable-next-line max-len
+        const parameters = { action: updateData['security:disableLinkSharing'] ? SupportedAction.ACTION_ADMIN_REJECT_SHARE_LINK : SupportedAction.ACTION_ADMIN_PERMIT_SHARE_LINK };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+        return res.apiv3({ securitySettingParams });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating security setting';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-secuirty-setting failed'));
+      }
+    });
 
 
   /**
@@ -868,7 +875,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: suceed to get all share links
    */
-  router.get('/all-share-links/', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.get('/all-share-links/', accessTokenParser([SCOPE.READ.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, async(req, res) => {
     const page = parseInt(req.query.page) || 1;
     const limit = 10;
     const linkQuery = {};
@@ -914,7 +921,7 @@ module.exports = (crowi) => {
    *                      type: number
    *                      description: total number of removed share links
    */
-  router.delete('/all-share-links/', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.delete('/all-share-links/', accessTokenParser([SCOPE.WRITE.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, async(req, res) => {
     try {
       const removedAct = await ShareLink.remove({});
       const removeTotal = await removedAct.n;
@@ -953,36 +960,38 @@ module.exports = (crowi) => {
    *                    localSettingParams:
    *                      $ref: '#/components/schemas/LocalSetting'
    */
-  router.put('/local-setting', loginRequiredStrictly, adminRequired, addActivity, validator.localSetting, apiV3FormValidator, async(req, res) => {
-    try {
-      const sanitizedRegistrationWhitelist = req.body.registrationWhitelist
-        .map(line => xss(line, { stripIgnoreTag: true }));
+  router.put('/local-setting', accessTokenParser([SCOPE.WRITE.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, addActivity,
+    validator.localSetting, apiV3FormValidator,
+    async(req, res) => {
+      try {
+        const sanitizedRegistrationWhitelist = req.body.registrationWhitelist
+          .map(line => xss(line, { stripIgnoreTag: true }));
 
-      const requestParams = {
-        'security:registrationMode': req.body.registrationMode,
-        'security:registrationWhitelist': sanitizedRegistrationWhitelist,
-        'security:passport-local:isPasswordResetEnabled': req.body.isPasswordResetEnabled,
-        'security:passport-local:isEmailAuthenticationEnabled': req.body.isEmailAuthenticationEnabled,
-      };
+        const requestParams = {
+          'security:registrationMode': req.body.registrationMode,
+          'security:registrationWhitelist': sanitizedRegistrationWhitelist,
+          'security:passport-local:isPasswordResetEnabled': req.body.isPasswordResetEnabled,
+          'security:passport-local:isEmailAuthenticationEnabled': req.body.isEmailAuthenticationEnabled,
+        };
 
-      await updateAndReloadStrategySettings('local', requestParams);
+        await updateAndReloadStrategySettings('local', requestParams);
 
-      const localSettingParams = {
-        registrationMode: await configManager.getConfig('security:registrationMode'),
-        registrationWhitelist: await configManager.getConfig('security:registrationWhitelist'),
-        isPasswordResetEnabled: await configManager.getConfig('security:passport-local:isPasswordResetEnabled'),
-        isEmailAuthenticationEnabled: await configManager.getConfig('security:passport-local:isEmailAuthenticationEnabled'),
-      };
-      const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_ID_PASS_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-      return res.apiv3({ localSettingParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating local setting';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-local-setting failed'));
-    }
-  });
+        const localSettingParams = {
+          registrationMode: await configManager.getConfig('security:registrationMode'),
+          registrationWhitelist: await configManager.getConfig('security:registrationWhitelist'),
+          isPasswordResetEnabled: await configManager.getConfig('security:passport-local:isPasswordResetEnabled'),
+          isEmailAuthenticationEnabled: await configManager.getConfig('security:passport-local:isEmailAuthenticationEnabled'),
+        };
+        const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_ID_PASS_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+        return res.apiv3({ localSettingParams });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating local setting';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-local-setting failed'));
+      }
+    });
 
   /**
    * @swagger
@@ -1010,49 +1019,51 @@ module.exports = (crowi) => {
    *                    securitySettingParams:
    *                      $ref: '#/components/schemas/LdapAuthSetting'
    */
-  router.put('/ldap', loginRequiredStrictly, adminRequired, addActivity, validator.ldapAuth, apiV3FormValidator, async(req, res) => {
-    const requestParams = {
-      'security:passport-ldap:serverUrl': req.body.serverUrl,
-      'security:passport-ldap:isUserBind': req.body.isUserBind,
-      'security:passport-ldap:bindDN': req.body.ldapBindDN,
-      'security:passport-ldap:bindDNPassword': req.body.ldapBindDNPassword,
-      'security:passport-ldap:searchFilter': req.body.ldapSearchFilter,
-      'security:passport-ldap:attrMapUsername': req.body.ldapAttrMapUsername,
-      'security:passport-ldap:isSameUsernameTreatedAsIdenticalUser': req.body.isSameUsernameTreatedAsIdenticalUser,
-      'security:passport-ldap:attrMapMail': req.body.ldapAttrMapMail,
-      'security:passport-ldap:attrMapName': req.body.ldapAttrMapName,
-      'security:passport-ldap:groupSearchBase': req.body.ldapGroupSearchBase,
-      'security:passport-ldap:groupSearchFilter': req.body.ldapGroupSearchFilter,
-      'security:passport-ldap:groupDnProperty': req.body.ldapGroupDnProperty,
-    };
+  router.put('/ldap', accessTokenParser([SCOPE.WRITE.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, addActivity,
+    validator.ldapAuth, apiV3FormValidator,
+    async(req, res) => {
+      const requestParams = {
+        'security:passport-ldap:serverUrl': req.body.serverUrl,
+        'security:passport-ldap:isUserBind': req.body.isUserBind,
+        'security:passport-ldap:bindDN': req.body.ldapBindDN,
+        'security:passport-ldap:bindDNPassword': req.body.ldapBindDNPassword,
+        'security:passport-ldap:searchFilter': req.body.ldapSearchFilter,
+        'security:passport-ldap:attrMapUsername': req.body.ldapAttrMapUsername,
+        'security:passport-ldap:isSameUsernameTreatedAsIdenticalUser': req.body.isSameUsernameTreatedAsIdenticalUser,
+        'security:passport-ldap:attrMapMail': req.body.ldapAttrMapMail,
+        'security:passport-ldap:attrMapName': req.body.ldapAttrMapName,
+        'security:passport-ldap:groupSearchBase': req.body.ldapGroupSearchBase,
+        'security:passport-ldap:groupSearchFilter': req.body.ldapGroupSearchFilter,
+        'security:passport-ldap:groupDnProperty': req.body.ldapGroupDnProperty,
+      };
 
-    try {
-      await updateAndReloadStrategySettings('ldap', requestParams);
+      try {
+        await updateAndReloadStrategySettings('ldap', requestParams);
 
-      const securitySettingParams = {
-        serverUrl: await configManager.getConfig('security:passport-ldap:serverUrl'),
-        isUserBind: await configManager.getConfig('security:passport-ldap:isUserBind'),
-        ldapBindDN: await configManager.getConfig('security:passport-ldap:bindDN'),
-        ldapBindDNPassword: await configManager.getConfig('security:passport-ldap:bindDNPassword'),
-        ldapSearchFilter: await configManager.getConfig('security:passport-ldap:searchFilter'),
-        ldapAttrMapUsername: await configManager.getConfig('security:passport-ldap:attrMapUsername'),
-        isSameUsernameTreatedAsIdenticalUser: await configManager.getConfig('security:passport-ldap:isSameUsernameTreatedAsIdenticalUser'),
-        ldapAttrMapMail: await configManager.getConfig('security:passport-ldap:attrMapMail'),
-        ldapAttrMapName: await configManager.getConfig('security:passport-ldap:attrMapName'),
-        ldapGroupSearchBase: await configManager.getConfig('security:passport-ldap:groupSearchBase'),
-        ldapGroupSearchFilter: await configManager.getConfig('security:passport-ldap:groupSearchFilter'),
-        ldapGroupDnProperty: await configManager.getConfig('security:passport-ldap:groupDnProperty'),
-      };
-      const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_LDAP_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-      return res.apiv3({ securitySettingParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating SAML setting';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-SAML-failed'));
-    }
-  });
+        const securitySettingParams = {
+          serverUrl: await configManager.getConfig('security:passport-ldap:serverUrl'),
+          isUserBind: await configManager.getConfig('security:passport-ldap:isUserBind'),
+          ldapBindDN: await configManager.getConfig('security:passport-ldap:bindDN'),
+          ldapBindDNPassword: await configManager.getConfig('security:passport-ldap:bindDNPassword'),
+          ldapSearchFilter: await configManager.getConfig('security:passport-ldap:searchFilter'),
+          ldapAttrMapUsername: await configManager.getConfig('security:passport-ldap:attrMapUsername'),
+          isSameUsernameTreatedAsIdenticalUser: await configManager.getConfig('security:passport-ldap:isSameUsernameTreatedAsIdenticalUser'),
+          ldapAttrMapMail: await configManager.getConfig('security:passport-ldap:attrMapMail'),
+          ldapAttrMapName: await configManager.getConfig('security:passport-ldap:attrMapName'),
+          ldapGroupSearchBase: await configManager.getConfig('security:passport-ldap:groupSearchBase'),
+          ldapGroupSearchFilter: await configManager.getConfig('security:passport-ldap:groupSearchFilter'),
+          ldapGroupDnProperty: await configManager.getConfig('security:passport-ldap:groupDnProperty'),
+        };
+        const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_LDAP_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+        return res.apiv3({ securitySettingParams });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating SAML setting';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-SAML-failed'));
+      }
+    });
 
   /**
    * @swagger
@@ -1080,78 +1091,80 @@ module.exports = (crowi) => {
    *                    securitySettingParams:
    *                      $ref: '#/components/schemas/SamlAuthSetting'
    */
-  router.put('/saml', loginRequiredStrictly, adminRequired, addActivity, validator.samlAuth, apiV3FormValidator, async(req, res) => {
-    const { t } = await getTranslation({ lang: req.user.lang, ns: ['translation', 'admin'] });
-
-    //  For the value of each mandatory items,
-    //  check whether it from the environment variables is empty and form value to update it is empty
-    //  validate the syntax of a attribute - based login control rule
-    const invalidValues = [];
-    for (const configKey of crowi.passportService.mandatoryConfigKeysForSaml) {
-      const key = configKey.replace('security:passport-saml:', '');
-      const formValue = req.body[key];
-      if (configManager.getConfig(configKey, ConfigSource.env) == null && formValue == null) {
-        const formItemName = t(`security_settings.form_item_name.${key}`);
-        invalidValues.push(t('input_validation.message.required', { param: formItemName }));
-      }
-    }
-    if (invalidValues.length !== 0) {
-      return res.apiv3Err(t('input_validation.message.error_message'), 400, invalidValues);
-    }
+  router.put('/saml', accessTokenParser([SCOPE.WRITE.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, addActivity,
+    validator.samlAuth, apiV3FormValidator,
+    async(req, res) => {
+      const { t } = await getTranslation({ lang: req.user.lang, ns: ['translation', 'admin'] });
 
-    const rule = req.body.ABLCRule;
-    // Empty string disables attribute-based login control.
-    // So, when rule is empty string, validation is passed.
-    if (rule != null) {
-      try {
-        crowi.passportService.parseABLCRule(rule);
+      //  For the value of each mandatory items,
+      //  check whether it from the environment variables is empty and form value to update it is empty
+      //  validate the syntax of a attribute - based login control rule
+      const invalidValues = [];
+      for (const configKey of crowi.passportService.mandatoryConfigKeysForSaml) {
+        const key = configKey.replace('security:passport-saml:', '');
+        const formValue = req.body[key];
+        if (configManager.getConfig(configKey, ConfigSource.env) == null && formValue == null) {
+          const formItemName = t(`security_settings.form_item_name.${key}`);
+          invalidValues.push(t('input_validation.message.required', { param: formItemName }));
+        }
       }
-      catch (err) {
-        return res.apiv3Err(t('input_validation.message.invalid_syntax', { syntax: t('security_settings.form_item_name.ABLCRule') }), 400);
+      if (invalidValues.length !== 0) {
+        return res.apiv3Err(t('input_validation.message.error_message'), 400, invalidValues);
       }
-    }
-
-    const requestParams = {
-      'security:passport-saml:entryPoint': req.body.entryPoint,
-      'security:passport-saml:issuer': req.body.issuer,
-      'security:passport-saml:cert': req.body.cert,
-      'security:passport-saml:attrMapId': req.body.attrMapId,
-      'security:passport-saml:attrMapUsername': req.body.attrMapUsername,
-      'security:passport-saml:attrMapMail': req.body.attrMapMail,
-      'security:passport-saml:attrMapFirstName': req.body.attrMapFirstName,
-      'security:passport-saml:attrMapLastName': req.body.attrMapLastName,
-      'security:passport-saml:isSameUsernameTreatedAsIdenticalUser': req.body.isSameUsernameTreatedAsIdenticalUser,
-      'security:passport-saml:isSameEmailTreatedAsIdenticalUser': req.body.isSameEmailTreatedAsIdenticalUser,
-      'security:passport-saml:ABLCRule': req.body.ABLCRule,
-    };
 
-    try {
-      await updateAndReloadStrategySettings('saml', requestParams);
+      const rule = req.body.ABLCRule;
+      // Empty string disables attribute-based login control.
+      // So, when rule is empty string, validation is passed.
+      if (rule != null) {
+        try {
+          crowi.passportService.parseABLCRule(rule);
+        }
+        catch (err) {
+          return res.apiv3Err(t('input_validation.message.invalid_syntax', { syntax: t('security_settings.form_item_name.ABLCRule') }), 400);
+        }
+      }
 
-      const securitySettingParams = {
-        missingMandatoryConfigKeys: await crowi.passportService.getSamlMissingMandatoryConfigKeys(),
-        samlEntryPoint: await configManager.getConfig('security:passport-saml:entryPoint', ConfigSource.db),
-        samlIssuer: await configManager.getConfig('security:passport-saml:issuer', ConfigSource.db),
-        samlCert: await configManager.getConfig('security:passport-saml:cert', ConfigSource.db),
-        samlAttrMapId: await configManager.getConfig('security:passport-saml:attrMapId', ConfigSource.db),
-        samlAttrMapUsername: await configManager.getConfig('security:passport-saml:attrMapUsername', ConfigSource.db),
-        samlAttrMapMail: await configManager.getConfig('security:passport-saml:attrMapMail', ConfigSource.db),
-        samlAttrMapFirstName: await configManager.getConfig('security:passport-saml:attrMapFirstName', ConfigSource.db),
-        samlAttrMapLastName: await configManager.getConfig('security:passport-saml:attrMapLastName', ConfigSource.db),
-        isSameUsernameTreatedAsIdenticalUser: await configManager.getConfig('security:passport-saml:isSameUsernameTreatedAsIdenticalUser'),
-        isSameEmailTreatedAsIdenticalUser: await configManager.getConfig('security:passport-saml:isSameEmailTreatedAsIdenticalUser'),
-        samlABLCRule: await configManager.getConfig('security:passport-saml:ABLCRule'),
+      const requestParams = {
+        'security:passport-saml:entryPoint': req.body.entryPoint,
+        'security:passport-saml:issuer': req.body.issuer,
+        'security:passport-saml:cert': req.body.cert,
+        'security:passport-saml:attrMapId': req.body.attrMapId,
+        'security:passport-saml:attrMapUsername': req.body.attrMapUsername,
+        'security:passport-saml:attrMapMail': req.body.attrMapMail,
+        'security:passport-saml:attrMapFirstName': req.body.attrMapFirstName,
+        'security:passport-saml:attrMapLastName': req.body.attrMapLastName,
+        'security:passport-saml:isSameUsernameTreatedAsIdenticalUser': req.body.isSameUsernameTreatedAsIdenticalUser,
+        'security:passport-saml:isSameEmailTreatedAsIdenticalUser': req.body.isSameEmailTreatedAsIdenticalUser,
+        'security:passport-saml:ABLCRule': req.body.ABLCRule,
       };
-      const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_SAML_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-      return res.apiv3({ securitySettingParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating SAML setting';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-SAML-failed'));
-    }
-  });
+
+      try {
+        await updateAndReloadStrategySettings('saml', requestParams);
+
+        const securitySettingParams = {
+          missingMandatoryConfigKeys: await crowi.passportService.getSamlMissingMandatoryConfigKeys(),
+          samlEntryPoint: await configManager.getConfig('security:passport-saml:entryPoint', ConfigSource.db),
+          samlIssuer: await configManager.getConfig('security:passport-saml:issuer', ConfigSource.db),
+          samlCert: await configManager.getConfig('security:passport-saml:cert', ConfigSource.db),
+          samlAttrMapId: await configManager.getConfig('security:passport-saml:attrMapId', ConfigSource.db),
+          samlAttrMapUsername: await configManager.getConfig('security:passport-saml:attrMapUsername', ConfigSource.db),
+          samlAttrMapMail: await configManager.getConfig('security:passport-saml:attrMapMail', ConfigSource.db),
+          samlAttrMapFirstName: await configManager.getConfig('security:passport-saml:attrMapFirstName', ConfigSource.db),
+          samlAttrMapLastName: await configManager.getConfig('security:passport-saml:attrMapLastName', ConfigSource.db),
+          isSameUsernameTreatedAsIdenticalUser: await configManager.getConfig('security:passport-saml:isSameUsernameTreatedAsIdenticalUser'),
+          isSameEmailTreatedAsIdenticalUser: await configManager.getConfig('security:passport-saml:isSameEmailTreatedAsIdenticalUser'),
+          samlABLCRule: await configManager.getConfig('security:passport-saml:ABLCRule'),
+        };
+        const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_SAML_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+        return res.apiv3({ securitySettingParams });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating SAML setting';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-SAML-failed'));
+      }
+    });
 
   /**
    * @swagger
@@ -1179,61 +1192,63 @@ module.exports = (crowi) => {
    *                    securitySettingParams:
    *                      $ref: '#/components/schemas/OidcAuthSetting'
    */
-  router.put('/oidc', loginRequiredStrictly, adminRequired, addActivity, validator.oidcAuth, apiV3FormValidator, async(req, res) => {
-    const requestParams = {
-      'security:passport-oidc:providerName': req.body.oidcProviderName,
-      'security:passport-oidc:issuerHost': req.body.oidcIssuerHost,
-      'security:passport-oidc:authorizationEndpoint': req.body.oidcAuthorizationEndpoint,
-      'security:passport-oidc:tokenEndpoint': req.body.oidcTokenEndpoint,
-      'security:passport-oidc:revocationEndpoint': req.body.oidcRevocationEndpoint,
-      'security:passport-oidc:introspectionEndpoint': req.body.oidcIntrospectionEndpoint,
-      'security:passport-oidc:userInfoEndpoint': req.body.oidcUserInfoEndpoint,
-      'security:passport-oidc:endSessionEndpoint': req.body.oidcEndSessionEndpoint,
-      'security:passport-oidc:registrationEndpoint': req.body.oidcRegistrationEndpoint,
-      'security:passport-oidc:jwksUri': req.body.oidcJWKSUri,
-      'security:passport-oidc:clientId': req.body.oidcClientId,
-      'security:passport-oidc:clientSecret': req.body.oidcClientSecret,
-      'security:passport-oidc:attrMapId': req.body.oidcAttrMapId,
-      'security:passport-oidc:attrMapUserName': req.body.oidcAttrMapUserName,
-      'security:passport-oidc:attrMapName': req.body.oidcAttrMapName,
-      'security:passport-oidc:attrMapMail': req.body.oidcAttrMapEmail,
-      'security:passport-oidc:isSameUsernameTreatedAsIdenticalUser': req.body.isSameUsernameTreatedAsIdenticalUser,
-      'security:passport-oidc:isSameEmailTreatedAsIdenticalUser': req.body.isSameEmailTreatedAsIdenticalUser,
-    };
+  router.put('/oidc', accessTokenParser([SCOPE.WRITE.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, addActivity,
+    validator.oidcAuth, apiV3FormValidator,
+    async(req, res) => {
+      const requestParams = {
+        'security:passport-oidc:providerName': req.body.oidcProviderName,
+        'security:passport-oidc:issuerHost': req.body.oidcIssuerHost,
+        'security:passport-oidc:authorizationEndpoint': req.body.oidcAuthorizationEndpoint,
+        'security:passport-oidc:tokenEndpoint': req.body.oidcTokenEndpoint,
+        'security:passport-oidc:revocationEndpoint': req.body.oidcRevocationEndpoint,
+        'security:passport-oidc:introspectionEndpoint': req.body.oidcIntrospectionEndpoint,
+        'security:passport-oidc:userInfoEndpoint': req.body.oidcUserInfoEndpoint,
+        'security:passport-oidc:endSessionEndpoint': req.body.oidcEndSessionEndpoint,
+        'security:passport-oidc:registrationEndpoint': req.body.oidcRegistrationEndpoint,
+        'security:passport-oidc:jwksUri': req.body.oidcJWKSUri,
+        'security:passport-oidc:clientId': req.body.oidcClientId,
+        'security:passport-oidc:clientSecret': req.body.oidcClientSecret,
+        'security:passport-oidc:attrMapId': req.body.oidcAttrMapId,
+        'security:passport-oidc:attrMapUserName': req.body.oidcAttrMapUserName,
+        'security:passport-oidc:attrMapName': req.body.oidcAttrMapName,
+        'security:passport-oidc:attrMapMail': req.body.oidcAttrMapEmail,
+        'security:passport-oidc:isSameUsernameTreatedAsIdenticalUser': req.body.isSameUsernameTreatedAsIdenticalUser,
+        'security:passport-oidc:isSameEmailTreatedAsIdenticalUser': req.body.isSameEmailTreatedAsIdenticalUser,
+      };
 
-    try {
-      await updateAndReloadStrategySettings('oidc', requestParams);
+      try {
+        await updateAndReloadStrategySettings('oidc', requestParams);
 
-      const securitySettingParams = {
-        oidcProviderName: await configManager.getConfig('security:passport-oidc:providerName'),
-        oidcIssuerHost: await configManager.getConfig('security:passport-oidc:issuerHost'),
-        oidcAuthorizationEndpoint: await configManager.getConfig('security:passport-oidc:authorizationEndpoint'),
-        oidcTokenEndpoint: await configManager.getConfig('security:passport-oidc:tokenEndpoint'),
-        oidcRevocationEndpoint: await configManager.getConfig('security:passport-oidc:revocationEndpoint'),
-        oidcIntrospectionEndpoint: await configManager.getConfig('security:passport-oidc:introspectionEndpoint'),
-        oidcUserInfoEndpoint: await configManager.getConfig('security:passport-oidc:userInfoEndpoint'),
-        oidcEndSessionEndpoint: await configManager.getConfig('security:passport-oidc:endSessionEndpoint'),
-        oidcRegistrationEndpoint: await configManager.getConfig('security:passport-oidc:registrationEndpoint'),
-        oidcJWKSUri: await configManager.getConfig('security:passport-oidc:jwksUri'),
-        oidcClientId: await configManager.getConfig('security:passport-oidc:clientId'),
-        oidcClientSecret: await configManager.getConfig('security:passport-oidc:clientSecret'),
-        oidcAttrMapId: await configManager.getConfig('security:passport-oidc:attrMapId'),
-        oidcAttrMapUserName: await configManager.getConfig('security:passport-oidc:attrMapUserName'),
-        oidcAttrMapName: await configManager.getConfig('security:passport-oidc:attrMapName'),
-        oidcAttrMapEmail: await configManager.getConfig('security:passport-oidc:attrMapMail'),
-        isSameUsernameTreatedAsIdenticalUser: await configManager.getConfig('security:passport-oidc:isSameUsernameTreatedAsIdenticalUser'),
-        isSameEmailTreatedAsIdenticalUser: await configManager.getConfig('security:passport-oidc:isSameEmailTreatedAsIdenticalUser'),
-      };
-      const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_OIDC_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-      return res.apiv3({ securitySettingParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating OpenIDConnect';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-OpenIDConnect-failed'));
-    }
-  });
+        const securitySettingParams = {
+          oidcProviderName: await configManager.getConfig('security:passport-oidc:providerName'),
+          oidcIssuerHost: await configManager.getConfig('security:passport-oidc:issuerHost'),
+          oidcAuthorizationEndpoint: await configManager.getConfig('security:passport-oidc:authorizationEndpoint'),
+          oidcTokenEndpoint: await configManager.getConfig('security:passport-oidc:tokenEndpoint'),
+          oidcRevocationEndpoint: await configManager.getConfig('security:passport-oidc:revocationEndpoint'),
+          oidcIntrospectionEndpoint: await configManager.getConfig('security:passport-oidc:introspectionEndpoint'),
+          oidcUserInfoEndpoint: await configManager.getConfig('security:passport-oidc:userInfoEndpoint'),
+          oidcEndSessionEndpoint: await configManager.getConfig('security:passport-oidc:endSessionEndpoint'),
+          oidcRegistrationEndpoint: await configManager.getConfig('security:passport-oidc:registrationEndpoint'),
+          oidcJWKSUri: await configManager.getConfig('security:passport-oidc:jwksUri'),
+          oidcClientId: await configManager.getConfig('security:passport-oidc:clientId'),
+          oidcClientSecret: await configManager.getConfig('security:passport-oidc:clientSecret'),
+          oidcAttrMapId: await configManager.getConfig('security:passport-oidc:attrMapId'),
+          oidcAttrMapUserName: await configManager.getConfig('security:passport-oidc:attrMapUserName'),
+          oidcAttrMapName: await configManager.getConfig('security:passport-oidc:attrMapName'),
+          oidcAttrMapEmail: await configManager.getConfig('security:passport-oidc:attrMapMail'),
+          isSameUsernameTreatedAsIdenticalUser: await configManager.getConfig('security:passport-oidc:isSameUsernameTreatedAsIdenticalUser'),
+          isSameEmailTreatedAsIdenticalUser: await configManager.getConfig('security:passport-oidc:isSameEmailTreatedAsIdenticalUser'),
+        };
+        const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_OIDC_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+        return res.apiv3({ securitySettingParams });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating OpenIDConnect';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-OpenIDConnect-failed'));
+      }
+    });
 
   /**
    * @swagger
@@ -1261,32 +1276,34 @@ module.exports = (crowi) => {
    *                    securitySettingParams:
    *                      $ref: '#/components/schemas/GoogleOAuthSetting'
    */
-  router.put('/google-oauth', loginRequiredStrictly, adminRequired, addActivity, validator.googleOAuth, apiV3FormValidator, async(req, res) => {
-    const requestParams = {
-      'security:passport-google:clientId': req.body.googleClientId,
-      'security:passport-google:clientSecret': req.body.googleClientSecret,
-      'security:passport-google:isSameEmailTreatedAsIdenticalUser': req.body.isSameEmailTreatedAsIdenticalUser,
-    };
+  router.put('/google-oauth', accessTokenParser([SCOPE.WRITE.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, addActivity,
+    validator.googleOAuth, apiV3FormValidator,
+    async(req, res) => {
+      const requestParams = {
+        'security:passport-google:clientId': req.body.googleClientId,
+        'security:passport-google:clientSecret': req.body.googleClientSecret,
+        'security:passport-google:isSameEmailTreatedAsIdenticalUser': req.body.isSameEmailTreatedAsIdenticalUser,
+      };
 
 
-    try {
-      await updateAndReloadStrategySettings('google', requestParams);
+      try {
+        await updateAndReloadStrategySettings('google', requestParams);
 
-      const securitySettingParams = {
-        googleClientId: await configManager.getConfig('security:passport-google:clientId'),
-        googleClientSecret: await configManager.getConfig('security:passport-google:clientSecret'),
-        isSameEmailTreatedAsIdenticalUser: await configManager.getConfig('security:passport-google:isSameEmailTreatedAsIdenticalUser'),
-      };
-      const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_GOOGLE_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-      return res.apiv3({ securitySettingParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating googleOAuth';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-googleOAuth-failed'));
-    }
-  });
+        const securitySettingParams = {
+          googleClientId: await configManager.getConfig('security:passport-google:clientId'),
+          googleClientSecret: await configManager.getConfig('security:passport-google:clientSecret'),
+          isSameEmailTreatedAsIdenticalUser: await configManager.getConfig('security:passport-google:isSameEmailTreatedAsIdenticalUser'),
+        };
+        const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_GOOGLE_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+        return res.apiv3({ securitySettingParams });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating googleOAuth';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-googleOAuth-failed'));
+      }
+    });
 
   /**
    * @swagger
@@ -1314,33 +1331,35 @@ module.exports = (crowi) => {
    *                    securitySettingParams:
    *                      $ref: '#/components/schemas/GitHubOAuthSetting'
    */
-  router.put('/github-oauth', loginRequiredStrictly, adminRequired, addActivity, validator.githubOAuth, apiV3FormValidator, async(req, res) => {
-    const requestParams = {
-      'security:passport-github:clientId': req.body.githubClientId,
-      'security:passport-github:clientSecret': req.body.githubClientSecret,
-      'security:passport-github:isSameUsernameTreatedAsIdenticalUser': req.body.isSameUsernameTreatedAsIdenticalUser,
-    };
+  router.put('/github-oauth', accessTokenParser([SCOPE.WRITE.ADMIN.SECURITY]), loginRequiredStrictly, adminRequired, addActivity,
+    validator.githubOAuth, apiV3FormValidator,
+    async(req, res) => {
+      const requestParams = {
+        'security:passport-github:clientId': req.body.githubClientId,
+        'security:passport-github:clientSecret': req.body.githubClientSecret,
+        'security:passport-github:isSameUsernameTreatedAsIdenticalUser': req.body.isSameUsernameTreatedAsIdenticalUser,
+      };
 
-    try {
-      await updateAndReloadStrategySettings('github', requestParams);
+      try {
+        await updateAndReloadStrategySettings('github', requestParams);
 
-      const securitySettingParams = {
-        githubClientId: await configManager.getConfig('security:passport-github:clientId'),
-        githubClientSecret: await configManager.getConfig('security:passport-github:clientSecret'),
-        isSameUsernameTreatedAsIdenticalUser: await configManager.getConfig('security:passport-github:isSameUsernameTreatedAsIdenticalUser'),
-      };
-      const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_GITHUB_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-      return res.apiv3({ securitySettingParams });
-    }
-    catch (err) {
+        const securitySettingParams = {
+          githubClientId: await configManager.getConfig('security:passport-github:clientId'),
+          githubClientSecret: await configManager.getConfig('security:passport-github:clientSecret'),
+          isSameUsernameTreatedAsIdenticalUser: await configManager.getConfig('security:passport-github:isSameUsernameTreatedAsIdenticalUser'),
+        };
+        const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_GITHUB_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+        return res.apiv3({ securitySettingParams });
+      }
+      catch (err) {
       // reset strategy
-      await crowi.passportService.resetGitHubStrategy();
-      const msg = 'Error occurred in updating githubOAuth';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-githubOAuth-failed'));
-    }
-  });
+        await crowi.passportService.resetGitHubStrategy();
+        const msg = 'Error occurred in updating githubOAuth';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-githubOAuth-failed'));
+      }
+    });
 
   return router;
 };

+ 114 - 89
apps/app/src/server/routes/apiv3/share-links.js

@@ -4,6 +4,8 @@ import { ErrorV3 } from '@growi/core/dist/models';
 import express from 'express';
 
 import { SupportedAction } from '~/interfaces/activity';
+import { SCOPE } from '~/interfaces/scope';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
@@ -136,27 +138,33 @@ module.exports = (crowi) => {
    *                      items:
    *                        $ref: '#/components/schemas/ShareLink'
    */
-  router.get('/', loginRequired, linkSharingRequired, validator.getShareLinks, apiV3FormValidator, async(req, res) => {
-    const { relatedPage } = req.query;
-
-    const page = await Page.findByIdAndViewer(relatedPage, req.user);
-
-    if (page == null) {
-      const msg = 'Page is not found or forbidden';
-      logger.error('Error', msg);
-      return res.apiv3Err(new ErrorV3(msg, 'get-shareLink-failed'));
-    }
+  router.get('/',
+    accessTokenParser([SCOPE.READ.FEATURES.SHARE_LINK]),
+    loginRequired,
+    linkSharingRequired,
+    validator.getShareLinks,
+    apiV3FormValidator,
+    async(req, res) => {
+      const { relatedPage } = req.query;
+
+      const page = await Page.findByIdAndViewer(relatedPage, req.user);
+
+      if (page == null) {
+        const msg = 'Page is not found or forbidden';
+        logger.error('Error', msg);
+        return res.apiv3Err(new ErrorV3(msg, 'get-shareLink-failed'));
+      }
 
-    try {
-      const shareLinksResult = await ShareLink.find({ relatedPage }).populate({ path: 'relatedPage', select: 'path' });
-      return res.apiv3({ shareLinksResult });
-    }
-    catch (err) {
-      const msg = 'Error occurred in get share link';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'get-shareLink-failed'));
-    }
-  });
+      try {
+        const shareLinksResult = await ShareLink.find({ relatedPage }).populate({ path: 'relatedPage', select: 'path' });
+        return res.apiv3({ shareLinksResult });
+      }
+      catch (err) {
+        const msg = 'Error occurred in get share link';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'get-shareLink-failed'));
+      }
+    });
 
   validator.shareLinkStatus = [
     // validate the page id is MongoId
@@ -202,30 +210,38 @@ module.exports = (crowi) => {
    *                schema:
    *                 $ref: '#/components/schemas/ShareLinkSimple'
    */
-  router.post('/', loginRequired, excludeReadOnlyUser, linkSharingRequired, addActivity, validator.shareLinkStatus, apiV3FormValidator, async(req, res) => {
-    const { relatedPage, expiredAt, description } = req.body;
-
-    const page = await Page.findByIdAndViewer(relatedPage, req.user);
-
-    if (page == null) {
-      const msg = 'Page is not found or forbidden';
-      logger.error('Error', msg);
-      return res.apiv3Err(new ErrorV3(msg, 'post-shareLink-failed'));
-    }
+  router.post('/',
+    accessTokenParser([SCOPE.WRITE.FEATURES.SHARE_LINK]),
+    loginRequired,
+    excludeReadOnlyUser,
+    linkSharingRequired,
+    addActivity,
+    validator.shareLinkStatus,
+    apiV3FormValidator,
+    async(req, res) => {
+      const { relatedPage, expiredAt, description } = req.body;
+
+      const page = await Page.findByIdAndViewer(relatedPage, req.user);
+
+      if (page == null) {
+        const msg = 'Page is not found or forbidden';
+        logger.error('Error', msg);
+        return res.apiv3Err(new ErrorV3(msg, 'post-shareLink-failed'));
+      }
 
-    try {
-      const postedShareLink = await ShareLink.create({ relatedPage, expiredAt, description });
+      try {
+        const postedShareLink = await ShareLink.create({ relatedPage, expiredAt, description });
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_SHARE_LINK_CREATE });
+        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_SHARE_LINK_CREATE });
 
-      return res.apiv3(postedShareLink, 201);
-    }
-    catch (err) {
-      const msg = 'Error occured in post share link';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'post-shareLink-failed'));
-    }
-  });
+        return res.apiv3(postedShareLink, 201);
+      }
+      catch (err) {
+        const msg = 'Error occured in post share link';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'post-shareLink-failed'));
+      }
+    });
 
 
   validator.deleteShareLinks = [
@@ -258,29 +274,36 @@ module.exports = (crowi) => {
   *                schema:
   *                 $ref: '#/components/schemas/ShareLinkSimple'
   */
-  router.delete('/', loginRequired, excludeReadOnlyUser, addActivity, validator.deleteShareLinks, apiV3FormValidator, async(req, res) => {
-    const { relatedPage } = req.query;
-    const page = await Page.findByIdAndViewer(relatedPage, req.user);
-
-    if (page == null) {
-      const msg = 'Page is not found or forbidden';
-      logger.error('Error', msg);
-      return res.apiv3Err(new ErrorV3(msg, 'delete-shareLinks-for-page-failed'));
-    }
+  router.delete('/',
+    accessTokenParser([SCOPE.WRITE.FEATURES.SHARE_LINK]),
+    loginRequired,
+    excludeReadOnlyUser,
+    addActivity,
+    validator.deleteShareLinks,
+    apiV3FormValidator,
+    async(req, res) => {
+      const { relatedPage } = req.query;
+      const page = await Page.findByIdAndViewer(relatedPage, req.user);
+
+      if (page == null) {
+        const msg = 'Page is not found or forbidden';
+        logger.error('Error', msg);
+        return res.apiv3Err(new ErrorV3(msg, 'delete-shareLinks-for-page-failed'));
+      }
 
-    try {
-      const deletedShareLink = await ShareLink.remove({ relatedPage });
+      try {
+        const deletedShareLink = await ShareLink.remove({ relatedPage });
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_SHARE_LINK_DELETE_BY_PAGE });
+        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_SHARE_LINK_DELETE_BY_PAGE });
 
-      return res.apiv3(deletedShareLink);
-    }
-    catch (err) {
-      const msg = 'Error occured in delete share link';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'delete-shareLink-failed'));
-    }
-  });
+        return res.apiv3(deletedShareLink);
+      }
+      catch (err) {
+        const msg = 'Error occured in delete share link';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'delete-shareLink-failed'));
+      }
+    });
 
   /**
   * @swagger
@@ -303,7 +326,7 @@ module.exports = (crowi) => {
   *                      type: integer
   *                      description: The number of share links deleted
   */
-  router.delete('/all', loginRequired, adminRequired, addActivity, async(req, res) => {
+  router.delete('/all', accessTokenParser([SCOPE.WRITE.FEATURES.SHARE_LINK]), loginRequired, adminRequired, addActivity, async(req, res) => {
 
     try {
       const deletedShareLink = await ShareLink.deleteMany({});
@@ -344,38 +367,40 @@ module.exports = (crowi) => {
   *          200:
   *            description: Succeeded to delete one share link
   */
-  router.delete('/:id', loginRequired, excludeReadOnlyUser, addActivity, validator.deleteShareLink, apiV3FormValidator, async(req, res) => {
-    const { id } = req.params;
-    const { user } = req;
-
-    try {
-      const shareLinkToDelete = await ShareLink.findOne({ _id: id });
-
-      // check permission
-      if (!user.isAdmin) {
-        const page = await Page.findByIdAndViewer(shareLinkToDelete.relatedPage, user);
-        const isPageExists = (await Page.count({ _id: shareLinkToDelete.relatedPage }) > 0);
-        if (page == null && isPageExists) {
-          const msg = 'Page is not found or forbidden';
-          logger.error('Error', msg);
-          return res.apiv3Err(new ErrorV3(msg, 'delete-shareLink-failed'));
+  router.delete('/:id', accessTokenParser([SCOPE.WRITE.FEATURES.SHARE_LINK]), loginRequired, excludeReadOnlyUser, addActivity,
+    validator.deleteShareLink, apiV3FormValidator,
+    async(req, res) => {
+      const { id } = req.params;
+      const { user } = req;
+
+      try {
+        const shareLinkToDelete = await ShareLink.findOne({ _id: id });
+
+        // check permission
+        if (!user.isAdmin) {
+          const page = await Page.findByIdAndViewer(shareLinkToDelete.relatedPage, user);
+          const isPageExists = (await Page.count({ _id: shareLinkToDelete.relatedPage }) > 0);
+          if (page == null && isPageExists) {
+            const msg = 'Page is not found or forbidden';
+            logger.error('Error', msg);
+            return res.apiv3Err(new ErrorV3(msg, 'delete-shareLink-failed'));
+          }
         }
-      }
 
-      // remove
-      await shareLinkToDelete.remove();
+        // remove
+        await shareLinkToDelete.remove();
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_SHARE_LINK_DELETE });
+        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_SHARE_LINK_DELETE });
 
-      return res.apiv3({ deletedShareLink: shareLinkToDelete });
-    }
-    catch (err) {
-      const msg = 'Error occurred in delete share link';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'delete-shareLink-failed'));
-    }
+        return res.apiv3({ deletedShareLink: shareLinkToDelete });
+      }
+      catch (err) {
+        const msg = 'Error occurred in delete share link';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'delete-shareLink-failed'));
+      }
 
-  });
+    });
 
 
   return router;

+ 29 - 26
apps/app/src/server/routes/apiv3/slack-integration-legacy-settings.js

@@ -3,6 +3,8 @@ import express from 'express';
 import { body } from 'express-validator';
 
 import { SupportedAction } from '~/interfaces/activity';
+import { SCOPE } from '~/interfaces/scope';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 
@@ -75,7 +77,7 @@ module.exports = (crowi) => {
    *                              type: boolean
    *                              description: whether slackbot is configured
    */
-  router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.LEGACY_SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, async(req, res) => {
 
     const slackIntegrationParams = {
       isSlackbotConfigured: crowi.slackIntegrationService.isSlackbotConfigured,
@@ -121,34 +123,35 @@ module.exports = (crowi) => {
    *                      type: object
    *                      $ref: '#/components/schemas/SlackConfigurationParams'
    */
-  router.put('/', loginRequiredStrictly, adminRequired, addActivity, validator.slackConfiguration, apiV3FormValidator, async(req, res) => {
+  router.put('/', accessTokenParser([SCOPE.WRITE.ADMIN.LEGACY_SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, addActivity,
+    validator.slackConfiguration, apiV3FormValidator, async(req, res) => {
 
-    const requestParams = {
-      'slack:incomingWebhookUrl': req.body.webhookUrl,
-      'slack:isIncomingWebhookPrioritized': req.body.isIncomingWebhookPrioritized,
-      'slack:token': req.body.slackToken,
-    };
-
-    try {
-      await configManager.updateConfigs(requestParams);
-      const responseParams = {
-        webhookUrl: await crowi.configManager.getConfig('slack:incomingWebhookUrl'),
-        isIncomingWebhookPrioritized: await crowi.configManager.getConfig('slack:isIncomingWebhookPrioritized'),
-        slackToken: await crowi.configManager.getConfig('slack:token'),
+      const requestParams = {
+        'slack:incomingWebhookUrl': req.body.webhookUrl,
+        'slack:isIncomingWebhookPrioritized': req.body.isIncomingWebhookPrioritized,
+        'slack:token': req.body.slackToken,
       };
 
-      const parameters = { action: SupportedAction.ACTION_ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-
-      return res.apiv3({ responseParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating slack configuration';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-slackConfiguration-failed'));
-    }
-
-  });
+      try {
+        await configManager.updateConfigs(requestParams);
+        const responseParams = {
+          webhookUrl: await crowi.configManager.getConfig('slack:incomingWebhookUrl'),
+          isIncomingWebhookPrioritized: await crowi.configManager.getConfig('slack:isIncomingWebhookPrioritized'),
+          slackToken: await crowi.configManager.getConfig('slack:token'),
+        };
+
+        const parameters = { action: SupportedAction.ACTION_ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3({ responseParams });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating slack configuration';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-slackConfiguration-failed'));
+      }
+
+    });
 
   return router;
 };

+ 122 - 114
apps/app/src/server/routes/apiv3/slack-integration-settings.js

@@ -10,13 +10,13 @@ import {
 } from '@growi/slack/dist/utils/check-communicable';
 
 import { SupportedAction } from '~/interfaces/activity';
+import { SCOPE } from '~/interfaces/scope';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
-
 const axios = require('axios');
 const express = require('express');
 const { body, param } = require('express-validator');
@@ -197,7 +197,7 @@ module.exports = (crowi) => {
    *                  errorCode:
    *                    type: string
    */
-  router.get('/', accessTokenParser(), loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, async(req, res) => {
 
     const { configManager, slackIntegrationService } = crowi;
     const currentBotType = configManager.getConfig('slackbot:currentBotType');
@@ -333,7 +333,7 @@ module.exports = (crowi) => {
    *             description: Succeeded to put botType setting.
    */
   // eslint-disable-next-line max-len
-  router.put('/bot-type', accessTokenParser(), loginRequiredStrictly, adminRequired, addActivity, validator.botType, apiV3FormValidator, async(req, res) => {
+  router.put('/bot-type', accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, addActivity, validator.botType, apiV3FormValidator, async(req, res) => {
     const { currentBotType } = req.body;
 
     if (currentBotType == null) {
@@ -370,18 +370,19 @@ module.exports = (crowi) => {
    *           200:
    *             description: Succeeded to delete botType setting.
    */
-  router.delete('/bot-type', accessTokenParser(), loginRequiredStrictly, adminRequired, addActivity, apiV3FormValidator, async(req, res) => {
-    try {
-      await handleBotTypeChanging(req, res, null);
+  router.delete('/bot-type', accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, addActivity, apiV3FormValidator,
+    async(req, res) => {
+      try {
+        await handleBotTypeChanging(req, res, null);
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_BOT_TYPE_DELETE });
-    }
-    catch (error) {
-      const msg = 'Error occured in resetting all';
-      logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'resetting-all-failed'), 500);
-    }
-  });
+        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_BOT_TYPE_DELETE });
+      }
+      catch (error) {
+        const msg = 'Error occured in resetting all';
+        logger.error('Error', error);
+        return res.apiv3Err(new ErrorV3(msg, 'resetting-all-failed'), 500);
+      }
+    });
 
   /**
    * @swagger
@@ -408,32 +409,33 @@ module.exports = (crowi) => {
    *           200:
    *             description: Succeeded to put CustomBotWithoutProxy setting.
    */
-  router.put('/without-proxy/update-settings', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
-    const currentBotType = crowi.configManager.getConfig('slackbot:currentBotType');
-    if (currentBotType !== SlackbotType.CUSTOM_WITHOUT_PROXY) {
-      const msg = 'Not CustomBotWithoutProxy';
-      return res.apiv3Err(new ErrorV3(msg, 'not-customBotWithoutProxy'), 400);
-    }
+  router.put('/without-proxy/update-settings', accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, addActivity,
+    async(req, res) => {
+      const currentBotType = crowi.configManager.getConfig('slackbot:currentBotType');
+      if (currentBotType !== SlackbotType.CUSTOM_WITHOUT_PROXY) {
+        const msg = 'Not CustomBotWithoutProxy';
+        return res.apiv3Err(new ErrorV3(msg, 'not-customBotWithoutProxy'), 400);
+      }
 
-    const { slackSigningSecret, slackBotToken } = req.body;
-    const requestParams = {
-      'slackbot:withoutProxy:signingSecret': slackSigningSecret,
-      'slackbot:withoutProxy:botToken': slackBotToken,
-    };
-    try {
-      await updateSlackBotSettings(requestParams);
-      crowi.slackIntegrationService.publishUpdatedMessage();
+      const { slackSigningSecret, slackBotToken } = req.body;
+      const requestParams = {
+        'slackbot:withoutProxy:signingSecret': slackSigningSecret,
+        'slackbot:withoutProxy:botToken': slackBotToken,
+      };
+      try {
+        await updateSlackBotSettings(requestParams);
+        crowi.slackIntegrationService.publishUpdatedMessage();
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_WITHOUT_PROXY_SETTINGS_UPDATE });
+        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_WITHOUT_PROXY_SETTINGS_UPDATE });
 
-      return res.apiv3();
-    }
-    catch (error) {
-      const msg = 'Error occured in updating Custom bot setting';
-      logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'update-CustomBotSetting-failed'), 500);
-    }
-  });
+        return res.apiv3();
+      }
+      catch (error) {
+        const msg = 'Error occured in updating Custom bot setting';
+        logger.error('Error', error);
+        return res.apiv3Err(new ErrorV3(msg, 'update-CustomBotSetting-failed'), 500);
+      }
+    });
 
   /**
    * @swagger
@@ -461,7 +463,7 @@ module.exports = (crowi) => {
    *             description: Succeeded to put CustomBotWithoutProxy permissions.
    */
   // eslint-disable-next-line max-len
-  router.put('/without-proxy/update-permissions', loginRequiredStrictly, adminRequired, addActivity, validator.updatePermissionsWithoutProxy, async(req, res) => {
+  router.put('/without-proxy/update-permissions', accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, addActivity, validator.updatePermissionsWithoutProxy, async(req, res) => {
     const currentBotType = crowi.configManager.getConfig('slackbot:currentBotType');
     if (currentBotType !== SlackbotType.CUSTOM_WITHOUT_PROXY) {
       const msg = 'Not CustomBotWithoutProxy';
@@ -520,42 +522,43 @@ module.exports = (crowi) => {
    *                  isPrimary:
    *                    type: boolean
    */
-  router.post('/slack-app-integrations', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
-    const SlackAppIntegrationRecordsNum = await SlackAppIntegration.countDocuments();
-    if (SlackAppIntegrationRecordsNum >= 10) {
-      const msg = 'Not be able to create more than 10 slack workspace integration settings';
-      logger.error('Error', msg);
-      return res.apiv3Err(new ErrorV3(msg, 'create-slackAppIntegeration-failed'), 500);
-    }
-
-    const count = await SlackAppIntegration.count();
-
-    const { tokenGtoP, tokenPtoG } = await SlackAppIntegration.generateUniqueAccessTokens();
-    try {
-      const initialSupportedCommandsForBroadcastUse = new Map(defaultSupportedCommandsNameForBroadcastUse.map(command => [command, true]));
-      const initialSupportedCommandsForSingleUse = new Map(defaultSupportedCommandsNameForSingleUse.map(command => [command, true]));
-      const initialPermissionsForSlackEventActions = new Map(defaultSupportedSlackEventActions.map(action => [action, true]));
-
-      const slackAppTokens = await SlackAppIntegration.create({
-        tokenGtoP,
-        tokenPtoG,
-        permissionsForBroadcastUseCommands: initialSupportedCommandsForBroadcastUse,
-        permissionsForSingleUseCommands: initialSupportedCommandsForSingleUse,
-        permissionsForSlackEvents: initialPermissionsForSlackEventActions,
-        isPrimary: count === 0,
-      });
+  router.post('/slack-app-integrations', accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, addActivity,
+    async(req, res) => {
+      const SlackAppIntegrationRecordsNum = await SlackAppIntegration.countDocuments();
+      if (SlackAppIntegrationRecordsNum >= 10) {
+        const msg = 'Not be able to create more than 10 slack workspace integration settings';
+        logger.error('Error', msg);
+        return res.apiv3Err(new ErrorV3(msg, 'create-slackAppIntegeration-failed'), 500);
+      }
 
-      const parameters = { action: SupportedAction.ACTION_ADMIN_SLACK_WORKSPACE_CREATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
+      const count = await SlackAppIntegration.count();
 
-      return res.apiv3(slackAppTokens, 200);
-    }
-    catch (error) {
-      const msg = 'Error occurred during creating slack integration settings procedure';
-      logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'creating-slack-integration-settings-procedure-failed'), 500);
-    }
-  });
+      const { tokenGtoP, tokenPtoG } = await SlackAppIntegration.generateUniqueAccessTokens();
+      try {
+        const initialSupportedCommandsForBroadcastUse = new Map(defaultSupportedCommandsNameForBroadcastUse.map(command => [command, true]));
+        const initialSupportedCommandsForSingleUse = new Map(defaultSupportedCommandsNameForSingleUse.map(command => [command, true]));
+        const initialPermissionsForSlackEventActions = new Map(defaultSupportedSlackEventActions.map(action => [action, true]));
+
+        const slackAppTokens = await SlackAppIntegration.create({
+          tokenGtoP,
+          tokenPtoG,
+          permissionsForBroadcastUseCommands: initialSupportedCommandsForBroadcastUse,
+          permissionsForSingleUseCommands: initialSupportedCommandsForSingleUse,
+          permissionsForSlackEvents: initialPermissionsForSlackEventActions,
+          isPrimary: count === 0,
+        });
+
+        const parameters = { action: SupportedAction.ACTION_ADMIN_SLACK_WORKSPACE_CREATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3(slackAppTokens, 200);
+      }
+      catch (error) {
+        const msg = 'Error occurred during creating slack integration settings procedure';
+        logger.error('Error', error);
+        return res.apiv3Err(new ErrorV3(msg, 'creating-slack-integration-settings-procedure-failed'), 500);
+      }
+    });
 
   /**
    * @swagger
@@ -583,7 +586,8 @@ module.exports = (crowi) => {
    *                  response:
    *                    type: object
    */
-  router.delete('/slack-app-integrations/:id', loginRequiredStrictly, adminRequired, validator.deleteIntegration, apiV3FormValidator, addActivity,
+  router.delete('/slack-app-integrations/:id', accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired,
+    validator.deleteIntegration, apiV3FormValidator, addActivity,
     async(req, res) => {
       const { id } = req.params;
 
@@ -633,26 +637,28 @@ module.exports = (crowi) => {
    *               schema:
    *                 type: object
    */
-  router.put('/proxy-uri', loginRequiredStrictly, adminRequired, addActivity, validator.proxyUri, apiV3FormValidator, async(req, res) => {
-    const { proxyUri } = req.body;
+  router.put('/proxy-uri', accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, addActivity,
+    validator.proxyUri, apiV3FormValidator,
+    async(req, res) => {
+      const { proxyUri } = req.body;
 
-    const requestParams = { 'slackbot:proxyUri': proxyUri };
+      const requestParams = { 'slackbot:proxyUri': proxyUri };
 
-    try {
-      await updateSlackBotSettings(requestParams);
-      crowi.slackIntegrationService.publishUpdatedMessage();
+      try {
+        await updateSlackBotSettings(requestParams);
+        crowi.slackIntegrationService.publishUpdatedMessage();
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_PROXY_URI_UPDATE });
+        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_PROXY_URI_UPDATE });
 
-      return res.apiv3({});
-    }
-    catch (error) {
-      const msg = 'Error occured in updating Custom bot setting';
-      logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'delete-SlackAppIntegration-failed'), 500);
-    }
+        return res.apiv3({});
+      }
+      catch (error) {
+        const msg = 'Error occured in updating Custom bot setting';
+        logger.error('Error', error);
+        return res.apiv3Err(new ErrorV3(msg, 'delete-SlackAppIntegration-failed'), 500);
+      }
 
-  });
+    });
 
   /**
    * @swagger
@@ -676,7 +682,7 @@ module.exports = (crowi) => {
    *            description: Succeeded to make it primary
    */
   // eslint-disable-next-line max-len
-  router.put('/slack-app-integrations/:id/make-primary', loginRequiredStrictly, adminRequired, addActivity, validator.makePrimary, apiV3FormValidator, async(req, res) => {
+  router.put('/slack-app-integrations/:id/make-primary', accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, addActivity, validator.makePrimary, apiV3FormValidator, async(req, res) => {
 
     const { id } = req.params;
 
@@ -735,7 +741,7 @@ module.exports = (crowi) => {
    *                  type: object
    */
   // eslint-disable-next-line max-len
-  router.put('/slack-app-integrations/:id/regenerate-tokens', loginRequiredStrictly, adminRequired, addActivity, validator.regenerateTokens, apiV3FormValidator, async(req, res) => {
+  router.put('/slack-app-integrations/:id/regenerate-tokens', accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, addActivity, validator.regenerateTokens, apiV3FormValidator, async(req, res) => {
 
     const { id } = req.params;
 
@@ -792,7 +798,7 @@ module.exports = (crowi) => {
    *                  type: object
    */
   // eslint-disable-next-line max-len
-  router.put('/slack-app-integrations/:id/permissions', loginRequiredStrictly, adminRequired, addActivity, validator.updatePermissionsWithProxy, apiV3FormValidator, async(req, res) => {
+  router.put('/slack-app-integrations/:id/permissions', accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, addActivity, validator.updatePermissionsWithProxy, apiV3FormValidator, async(req, res) => {
     // TODO: look here 78975
     const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands, permissionsForSlackEventActions } = req.body;
     const { id } = req.params;
@@ -867,7 +873,7 @@ module.exports = (crowi) => {
    *             description: Succeeded to delete botType setting.
    */
   // eslint-disable-next-line max-len
-  router.post('/slack-app-integrations/:id/relation-test', loginRequiredStrictly, adminRequired, addActivity, validator.relationTest, apiV3FormValidator, async(req, res) => {
+  router.post('/slack-app-integrations/:id/relation-test', accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, addActivity, validator.relationTest, apiV3FormValidator, async(req, res) => {
     const currentBotType = crowi.configManager.getConfig('slackbot:currentBotType');
     if (currentBotType === SlackbotType.CUSTOM_WITHOUT_PROXY) {
       const msg = 'Not Proxy Type';
@@ -946,32 +952,34 @@ module.exports = (crowi) => {
    *           200:
    *             description: Succeeded to connect to slack work space.
    */
-  router.post('/without-proxy/test', loginRequiredStrictly, adminRequired, addActivity, validator.slackChannel, apiV3FormValidator, async(req, res) => {
-    const currentBotType = crowi.configManager.getConfig('slackbot:currentBotType');
-    if (currentBotType !== SlackbotType.CUSTOM_WITHOUT_PROXY) {
-      const msg = 'Select Without Proxy Type';
-      return res.apiv3Err(new ErrorV3(msg, 'select-not-proxy-type'), 400);
-    }
+  router.post('/without-proxy/test', accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, addActivity,
+    validator.slackChannel, apiV3FormValidator,
+    async(req, res) => {
+      const currentBotType = crowi.configManager.getConfig('slackbot:currentBotType');
+      if (currentBotType !== SlackbotType.CUSTOM_WITHOUT_PROXY) {
+        const msg = 'Select Without Proxy Type';
+        return res.apiv3Err(new ErrorV3(msg, 'select-not-proxy-type'), 400);
+      }
 
-    const slackBotToken = crowi.configManager.getConfig('slackbot:withoutProxy:botToken');
-    const status = await getConnectionStatus(slackBotToken);
-    if (status.error != null) {
-      return res.apiv3Err(new ErrorV3(`Error occured while getting connection. ${status.error}`, 'send-message-failed'));
-    }
+      const slackBotToken = crowi.configManager.getConfig('slackbot:withoutProxy:botToken');
+      const status = await getConnectionStatus(slackBotToken);
+      if (status.error != null) {
+        return res.apiv3Err(new ErrorV3(`Error occured while getting connection. ${status.error}`, 'send-message-failed'));
+      }
 
-    const { channel } = req.body;
-    const appSiteURL = crowi.configManager.getConfig('app:siteUrl');
-    try {
-      await sendSuccessMessage(slackBotToken, channel, appSiteURL);
-    }
-    catch (error) {
-      return res.apiv3Err(new ErrorV3(`Error occured while sending message. Cause: ${error.message}`, 'send-message-failed', error.stack));
-    }
+      const { channel } = req.body;
+      const appSiteURL = crowi.configManager.getConfig('app:siteUrl');
+      try {
+        await sendSuccessMessage(slackBotToken, channel, appSiteURL);
+      }
+      catch (error) {
+        return res.apiv3Err(new ErrorV3(`Error occured while sending message. Cause: ${error.message}`, 'send-message-failed', error.stack));
+      }
 
-    activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_WITHOUT_PROXY_TEST });
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_WITHOUT_PROXY_TEST });
 
-    return res.apiv3();
-  });
+      return res.apiv3();
+    });
 
   return router;
 };

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

@@ -1,6 +1,8 @@
 import { ErrorV3 } from '@growi/core/dist/models';
 import express from 'express';
 
+import { SCOPE } from '~/interfaces/scope';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { serializeUserGroupRelationSecurely } from '~/server/models/serializers';
 import UserGroupRelation from '~/server/models/user-group-relation';
 import loggerFactory from '~/utils/logger';
@@ -43,7 +45,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: contains arrays user objects related
    */
-  router.get('/', loginRequiredStrictly, adminRequired, validator.list, async(req, res) => {
+  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired, validator.list, async(req, res) => {
     const { query } = req;
 
     try {

+ 328 - 287
apps/app/src/server/routes/apiv3/user-group.js

@@ -7,6 +7,8 @@ import {
 } from 'express-validator';
 
 import { SupportedAction } from '~/interfaces/activity';
+import { SCOPE } from '~/interfaces/scope';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 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';
@@ -136,7 +138,7 @@ module.exports = (crowi) => {
    *                      type: number
    *                      description: the number of items per page
    */
-  router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired, async(req, res) => {
     const { query } = req;
 
     try {
@@ -190,20 +192,23 @@ module.exports = (crowi) => {
    *                        type: object
    *                      description: userGroup objects
    */
-  router.get('/ancestors', loginRequiredStrictly, adminRequired, validator.ancestorGroup, async(req, res) => {
-    const { groupId } = req.query;
-
-    try {
-      const userGroup = await UserGroup.findById(groupId);
-      const ancestorUserGroups = await UserGroup.findGroupsWithAncestorsRecursively(userGroup);
-      return res.apiv3({ ancestorUserGroups });
-    }
-    catch (err) {
-      const msg = 'Error occurred while searching user groups';
-      logger.error(msg, err);
-      return res.apiv3Err(new ErrorV3(msg, 'user-groups-search-failed'));
-    }
-  });
+  router.get('/ancestors',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+    validator.ancestorGroup,
+    async(req, res) => {
+      const { groupId } = req.query;
+
+      try {
+        const userGroup = await UserGroup.findById(groupId);
+        const ancestorUserGroups = await UserGroup.findGroupsWithAncestorsRecursively(userGroup);
+        return res.apiv3({ ancestorUserGroups });
+      }
+      catch (err) {
+        const msg = 'Error occurred while searching user groups';
+        logger.error(msg, err);
+        return res.apiv3Err(new ErrorV3(msg, 'user-groups-search-failed'));
+      }
+    });
 
   /**
    * @swagger
@@ -249,22 +254,25 @@ module.exports = (crowi) => {
    *                          type: object
    *                        description: Grandchild user group objects
    */
-  router.get('/children', loginRequiredStrictly, adminRequired, validator.listChildren, async(req, res) => {
-    try {
-      const { parentIds, includeGrandChildren = false } = req.query;
-
-      const userGroupsResult = await UserGroup.findChildrenByParentIds(parentIds, includeGrandChildren);
-      return res.apiv3({
-        childUserGroups: userGroupsResult.childUserGroups,
-        grandChildUserGroups: userGroupsResult.grandChildUserGroups,
-      });
-    }
-    catch (err) {
-      const msg = 'Error occurred in fetching child user group list';
-      logger.error(msg, err);
-      return res.apiv3Err(new ErrorV3(msg, 'child-user-group-list-fetch-failed'));
-    }
-  });
+  router.get('/children',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+    validator.listChildren,
+    async(req, res) => {
+      try {
+        const { parentIds, includeGrandChildren = false } = req.query;
+
+        const userGroupsResult = await UserGroup.findChildrenByParentIds(parentIds, includeGrandChildren);
+        return res.apiv3({
+          childUserGroups: userGroupsResult.childUserGroups,
+          grandChildUserGroups: userGroupsResult.grandChildUserGroups,
+        });
+      }
+      catch (err) {
+        const msg = 'Error occurred in fetching child user group list';
+        logger.error(msg, err);
+        return res.apiv3Err(new ErrorV3(msg, 'child-user-group-list-fetch-failed'));
+      }
+    });
 
 
   /**
@@ -305,25 +313,28 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: A result of `UserGroup.createGroupByName`
    */
-  router.post('/', loginRequiredStrictly, adminRequired, addActivity, validator.create, apiV3FormValidator, async(req, res) => {
-    const { name, description = '', parentId } = req.body;
-
-    try {
-      const userGroupName = generalXssFilter.process(name);
-      const userGroupDescription = generalXssFilter.process(description);
-      const userGroup = await UserGroup.createGroup(userGroupName, userGroupDescription, parentId);
-
-      const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_CREATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-
-      return res.apiv3({ userGroup }, 201);
-    }
-    catch (err) {
-      const msg = 'Error occurred in creating a user group';
-      logger.error(msg, err);
-      return res.apiv3Err(new ErrorV3(msg, 'user-group-create-failed'));
-    }
-  });
+  router.post('/',
+    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+    addActivity, validator.create, apiV3FormValidator,
+    async(req, res) => {
+      const { name, description = '', parentId } = req.body;
+
+      try {
+        const userGroupName = generalXssFilter.process(name);
+        const userGroupDescription = generalXssFilter.process(description);
+        const userGroup = await UserGroup.createGroup(userGroupName, userGroupDescription, parentId);
+
+        const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_CREATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3({ userGroup }, 201);
+      }
+      catch (err) {
+        const msg = 'Error occurred in creating a user group';
+        logger.error(msg, err);
+        return res.apiv3Err(new ErrorV3(msg, 'user-group-create-failed'));
+      }
+    });
 
   /**
    * @swagger
@@ -357,24 +368,27 @@ module.exports = (crowi) => {
    *                        type: object
    *                      description: userGroup objects
    */
-  router.get('/selectable-parent-groups', loginRequiredStrictly, adminRequired, validator.selectableGroups, async(req, res) => {
-    const { groupId } = req.query;
-
-    try {
-      const userGroup = await UserGroup.findById(groupId);
-
-      const descendantGroups = await UserGroup.findGroupsWithDescendantsRecursively([userGroup], []);
-      const descendantGroupIds = descendantGroups.map(userGroups => userGroups._id.toString());
-
-      const selectableParentGroups = await UserGroup.find({ _id: { $nin: [groupId, ...descendantGroupIds] } });
-      return res.apiv3({ selectableParentGroups });
-    }
-    catch (err) {
-      const msg = 'Error occurred while searching user groups';
-      logger.error(msg, err);
-      return res.apiv3Err(new ErrorV3(msg, 'user-groups-search-failed'));
-    }
-  });
+  router.get('/selectable-parent-groups',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+    validator.selectableGroups,
+    async(req, res) => {
+      const { groupId } = req.query;
+
+      try {
+        const userGroup = await UserGroup.findById(groupId);
+
+        const descendantGroups = await UserGroup.findGroupsWithDescendantsRecursively([userGroup], []);
+        const descendantGroupIds = descendantGroups.map(userGroups => userGroups._id.toString());
+
+        const selectableParentGroups = await UserGroup.find({ _id: { $nin: [groupId, ...descendantGroupIds] } });
+        return res.apiv3({ selectableParentGroups });
+      }
+      catch (err) {
+        const msg = 'Error occurred while searching user groups';
+        logger.error(msg, err);
+        return res.apiv3Err(new ErrorV3(msg, 'user-groups-search-failed'));
+      }
+    });
 
   /**
    * @swagger
@@ -408,27 +422,30 @@ module.exports = (crowi) => {
    *                        type: object
    *                      description: userGroup objects
    */
-  router.get('/selectable-child-groups', loginRequiredStrictly, adminRequired, validator.selectableGroups, async(req, res) => {
-    const { groupId } = req.query;
-
-    try {
-      const userGroup = await UserGroup.findById(groupId);
-
-      const [ancestorGroups, descendantGroups] = await Promise.all([
-        UserGroup.findGroupsWithAncestorsRecursively(userGroup, []),
-        UserGroup.findGroupsWithDescendantsRecursively([userGroup], []),
-      ]);
-
-      const excludeUserGroupIds = [userGroup, ...ancestorGroups, ...descendantGroups].map(userGroups => userGroups._id.toString());
-      const selectableChildGroups = await UserGroup.find({ _id: { $nin: excludeUserGroupIds } });
-      return res.apiv3({ selectableChildGroups });
-    }
-    catch (err) {
-      const msg = 'Error occurred while searching user groups';
-      logger.error(msg, err);
-      return res.apiv3Err(new ErrorV3(msg, 'user-groups-search-failed'));
-    }
-  });
+  router.get('/selectable-child-groups',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+    validator.selectableGroups,
+    async(req, res) => {
+      const { groupId } = req.query;
+
+      try {
+        const userGroup = await UserGroup.findById(groupId);
+
+        const [ancestorGroups, descendantGroups] = await Promise.all([
+          UserGroup.findGroupsWithAncestorsRecursively(userGroup, []),
+          UserGroup.findGroupsWithDescendantsRecursively([userGroup], []),
+        ]);
+
+        const excludeUserGroupIds = [userGroup, ...ancestorGroups, ...descendantGroups].map(userGroups => userGroups._id.toString());
+        const selectableChildGroups = await UserGroup.find({ _id: { $nin: excludeUserGroupIds } });
+        return res.apiv3({ selectableChildGroups });
+      }
+      catch (err) {
+        const msg = 'Error occurred while searching user groups';
+        logger.error(msg, err);
+        return res.apiv3Err(new ErrorV3(msg, 'user-groups-search-failed'));
+      }
+    });
 
   /**
    * @swagger
@@ -460,19 +477,22 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: userGroup object
    */
-  router.get('/:id', loginRequiredStrictly, adminRequired, validator.selectableGroups, async(req, res) => {
-    const { id: groupId } = req.params;
-
-    try {
-      const userGroup = await UserGroup.findById(groupId);
-      return res.apiv3({ userGroup });
-    }
-    catch (err) {
-      const msg = 'Error occurred while getting user group';
-      logger.error(msg, err);
-      return res.apiv3Err(new ErrorV3(msg, 'user-groups-get-failed'));
-    }
-  });
+  router.get('/:id',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+    validator.selectableGroups,
+    async(req, res) => {
+      const { id: groupId } = req.params;
+
+      try {
+        const userGroup = await UserGroup.findById(groupId);
+        return res.apiv3({ userGroup });
+      }
+      catch (err) {
+        const msg = 'Error occurred while getting user group';
+        logger.error(msg, err);
+        return res.apiv3Err(new ErrorV3(msg, 'user-groups-get-failed'));
+      }
+    });
 
   /**
    * @swagger
@@ -519,31 +539,34 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: A result of `UserGroup.removeCompletelyById`
    */
-  router.delete('/:id', loginRequiredStrictly, adminRequired, validator.delete, apiV3FormValidator, addActivity, async(req, res) => {
-    const { id: deleteGroupId } = req.params;
-    const { actionName, transferToUserGroupId, transferToUserGroupType } = req.query;
-
-    const transferToUserGroup = typeof transferToUserGroupId === 'string'
+  router.delete('/:id',
+    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+    validator.delete, apiV3FormValidator, addActivity,
+    async(req, res) => {
+      const { id: deleteGroupId } = req.params;
+      const { actionName, transferToUserGroupId, transferToUserGroupType } = req.query;
+
+      const transferToUserGroup = typeof transferToUserGroupId === 'string'
         && (transferToUserGroupType === GroupType.userGroup || transferToUserGroupType === GroupType.externalUserGroup)
-      ? {
-        item: transferToUserGroupId,
-        type: transferToUserGroupType,
-      } : undefined;
-
-    try {
-      const userGroups = await crowi.userGroupService.removeCompletelyByRootGroupId(deleteGroupId, actionName, req.user, transferToUserGroup);
-
-      const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_DELETE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-
-      return res.apiv3({ userGroups });
-    }
-    catch (err) {
-      const msg = 'Error occurred while deleting user groups';
-      logger.error(msg, err);
-      return res.apiv3Err(new ErrorV3(msg, 'user-groups-delete-failed'));
-    }
-  });
+        ? {
+          item: transferToUserGroupId,
+          type: transferToUserGroupType,
+        } : undefined;
+
+      try {
+        const userGroups = await crowi.userGroupService.removeCompletelyByRootGroupId(deleteGroupId, actionName, req.user, transferToUserGroup);
+
+        const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_DELETE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3({ userGroups });
+      }
+      catch (err) {
+        const msg = 'Error occurred while deleting user groups';
+        logger.error(msg, err);
+        return res.apiv3Err(new ErrorV3(msg, 'user-groups-delete-failed'));
+      }
+    });
 
   /**
    * @swagger
@@ -593,26 +616,29 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: A result of `UserGroup.updateName`
    */
-  router.put('/:id', loginRequiredStrictly, adminRequired, validator.update, apiV3FormValidator, addActivity, async(req, res) => {
-    const { id } = req.params;
-    const {
-      name, description, parentId, forceUpdateParents = false,
-    } = req.body;
-
-    try {
-      const userGroup = await crowi.userGroupService.updateGroup(id, name, description, parentId, forceUpdateParents);
-
-      const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-
-      return res.apiv3({ userGroup });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating a user group name';
-      logger.error(msg, err);
-      return res.apiv3Err(new ErrorV3(msg, 'user-group-update-failed'));
-    }
-  });
+  router.put('/:id',
+    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+    validator.update, apiV3FormValidator, addActivity,
+    async(req, res) => {
+      const { id } = req.params;
+      const {
+        name, description, parentId, forceUpdateParents = false,
+      } = req.body;
+
+      try {
+        const userGroup = await crowi.userGroupService.updateGroup(id, name, description, parentId, forceUpdateParents);
+
+        const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3({ userGroup });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating a user group name';
+        logger.error(msg, err);
+        return res.apiv3Err(new ErrorV3(msg, 'user-group-update-failed'));
+      }
+    });
 
 
   /**
@@ -647,26 +673,28 @@ module.exports = (crowi) => {
    *                        $ref: '#/components/schemas/User'
    *                      description: user objects
    */
-  router.get('/:id/users', loginRequiredStrictly, adminRequired, async(req, res) => {
-    const { id } = req.params;
-
-    try {
-      const userGroup = await UserGroup.findById(id);
-      const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
-
-      const serializeUsers = userGroupRelations.map((userGroupRelation) => {
-        return serializeUserSecurely(userGroupRelation.relatedUser);
-      });
-      const users = serializeUsers.filter(user => user != null);
-
-      return res.apiv3({ users });
-    }
-    catch (err) {
-      const msg = `Error occurred in fetching users for group: ${id}`;
-      logger.error(msg, err);
-      return res.apiv3Err(new ErrorV3(msg, 'user-group-user-list-fetch-failed'));
-    }
-  });
+  router.get('/:id/users',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+    async(req, res) => {
+      const { id } = req.params;
+
+      try {
+        const userGroup = await UserGroup.findById(id);
+        const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
+
+        const serializeUsers = userGroupRelations.map((userGroupRelation) => {
+          return serializeUserSecurely(userGroupRelation.relatedUser);
+        });
+        const users = serializeUsers.filter(user => user != null);
+
+        return res.apiv3({ users });
+      }
+      catch (err) {
+        const msg = `Error occurred in fetching users for group: ${id}`;
+        logger.error(msg, err);
+        return res.apiv3Err(new ErrorV3(msg, 'user-group-user-list-fetch-failed'));
+      }
+    });
 
   /**
    * @swagger
@@ -720,35 +748,37 @@ module.exports = (crowi) => {
    *                        $ref: '#/components/schemas/User'
    *                      description: user objects
    */
-  router.get('/:id/unrelated-users', loginRequiredStrictly, adminRequired, async(req, res) => {
-    const { id } = req.params;
-    const {
-      searchWord, searchType, isAlsoNameSearched, isAlsoMailSearched,
-    } = req.query;
-
-    const queryOptions = {
-      searchWord, searchType, isAlsoNameSearched, isAlsoMailSearched,
-    };
-
-    try {
-      const userGroup = await UserGroup.findById(id);
-      const users = await UserGroupRelation.findUserByNotRelatedGroup(userGroup, queryOptions);
-
-      // return email only this api
-      const serializedUsers = users.map((user) => {
-        const { email } = user;
-        const serializedUser = serializeUserSecurely(user);
-        serializedUser.email = email;
-        return serializedUser;
-      });
-      return res.apiv3({ users: serializedUsers });
-    }
-    catch (err) {
-      const msg = `Error occurred in fetching unrelated users for group: ${id}`;
-      logger.error(msg, err);
-      return res.apiv3Err(new ErrorV3(msg, 'user-group-unrelated-user-list-fetch-failed'));
-    }
-  });
+  router.get('/:id/unrelated-users',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+    async(req, res) => {
+      const { id } = req.params;
+      const {
+        searchWord, searchType, isAlsoNameSearched, isAlsoMailSearched,
+      } = req.query;
+
+      const queryOptions = {
+        searchWord, searchType, isAlsoNameSearched, isAlsoMailSearched,
+      };
+
+      try {
+        const userGroup = await UserGroup.findById(id);
+        const users = await UserGroupRelation.findUserByNotRelatedGroup(userGroup, queryOptions);
+
+        // return email only this api
+        const serializedUsers = users.map((user) => {
+          const { email } = user;
+          const serializedUser = serializeUserSecurely(user);
+          serializedUser.email = email;
+          return serializedUser;
+        });
+        return res.apiv3({ users: serializedUsers });
+      }
+      catch (err) {
+        const msg = `Error occurred in fetching unrelated users for group: ${id}`;
+        logger.error(msg, err);
+        return res.apiv3Err(new ErrorV3(msg, 'user-group-unrelated-user-list-fetch-failed'));
+      }
+    });
 
 
   /**
@@ -791,36 +821,39 @@ module.exports = (crowi) => {
    *                      type: number
    *                      description: the number of relations created
    */
-  router.post('/:id/users/:username', loginRequiredStrictly, adminRequired, validator.users.post, apiV3FormValidator, addActivity, async(req, res) => {
-    const { id, username } = req.params;
-
-    try {
-      const [userGroup, user] = await Promise.all([
-        UserGroup.findById(id),
-        User.findUserByUsername(username),
-      ]);
-
-      const userGroups = await UserGroup.findGroupsWithAncestorsRecursively(userGroup);
-      const userGroupIds = userGroups.map(g => g._id);
-
-      // remove existing relations from list to create
-      const existingRelations = await UserGroupRelation.find({ relatedGroup: { $in: userGroupIds }, relatedUser: user._id });
-      const existingGroupIds = existingRelations.map(r => r.relatedGroup);
-      const groupIdsToCreateRelation = excludeTestIdsFromTargetIds(userGroupIds, existingGroupIds);
-
-      const insertedRelations = await UserGroupRelation.createRelations(groupIdsToCreateRelation, user);
-      const serializedUser = serializeUserSecurely(user);
+  router.post('/:id/users/:username',
+    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+    validator.users.post, apiV3FormValidator, addActivity,
+    async(req, res) => {
+      const { id, username } = req.params;
+
+      try {
+        const [userGroup, user] = await Promise.all([
+          UserGroup.findById(id),
+          User.findUserByUsername(username),
+        ]);
+
+        const userGroups = await UserGroup.findGroupsWithAncestorsRecursively(userGroup);
+        const userGroupIds = userGroups.map(g => g._id);
+
+        // remove existing relations from list to create
+        const existingRelations = await UserGroupRelation.find({ relatedGroup: { $in: userGroupIds }, relatedUser: user._id });
+        const existingGroupIds = existingRelations.map(r => r.relatedGroup);
+        const groupIdsToCreateRelation = excludeTestIdsFromTargetIds(userGroupIds, existingGroupIds);
+
+        const insertedRelations = await UserGroupRelation.createRelations(groupIdsToCreateRelation, user);
+        const serializedUser = serializeUserSecurely(user);
 
-      const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_ADD_USER };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-      return res.apiv3({ user: serializedUser, createdRelationCount: insertedRelations.length });
-    }
-    catch (err) {
-      const msg = `Error occurred in adding the user "${username}" to group "${id}"`;
-      logger.error(msg, err);
-      return res.apiv3Err(new ErrorV3(msg, 'user-group-add-user-failed'));
-    }
-  });
+        const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_ADD_USER };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+        return res.apiv3({ user: serializedUser, createdRelationCount: insertedRelations.length });
+      }
+      catch (err) {
+        const msg = `Error occurred in adding the user "${username}" to group "${id}"`;
+        logger.error(msg, err);
+        return res.apiv3Err(new ErrorV3(msg, 'user-group-add-user-failed'));
+      }
+    });
 
 
   /**
@@ -864,21 +897,24 @@ module.exports = (crowi) => {
    *                      type: number
    *                      description: the number of groups from which the user was removed
    */
-  router.delete('/:id/users/:username', loginRequiredStrictly, adminRequired, validator.users.delete, apiV3FormValidator, async(req, res) => {
-    const { id: userGroupId, username } = req.params;
-
-    try {
-      const removedUserRes = await crowi.userGroupService.removeUserByUsername(userGroupId, username);
-      const serializedUser = serializeUserSecurely(removedUserRes.user);
-
-      return res.apiv3({ user: serializedUser, deletedGroupsCount: removedUserRes.deletedGroupsCount });
-    }
-    catch (err) {
-      const msg = 'Error occurred while removing the user from groups.';
-      logger.error(msg, err);
-      return res.apiv3Err(new ErrorV3(msg, 'user-group-remove-user-failed'));
-    }
-  });
+  router.delete('/:id/users/:username',
+    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+    validator.users.delete, apiV3FormValidator,
+    async(req, res) => {
+      const { id: userGroupId, username } = req.params;
+
+      try {
+        const removedUserRes = await crowi.userGroupService.removeUserByUsername(userGroupId, username);
+        const serializedUser = serializeUserSecurely(removedUserRes.user);
+
+        return res.apiv3({ user: serializedUser, deletedGroupsCount: removedUserRes.deletedGroupsCount });
+      }
+      catch (err) {
+        const msg = 'Error occurred while removing the user from groups.';
+        logger.error(msg, err);
+        return res.apiv3Err(new ErrorV3(msg, 'user-group-remove-user-failed'));
+      }
+    });
 
 
   /**
@@ -913,21 +949,23 @@ module.exports = (crowi) => {
    *                        type: object
    *                      description: userGroupRelation objects
    */
-  router.get('/:id/user-group-relations', loginRequiredStrictly, adminRequired, async(req, res) => {
-    const { id } = req.params;
-
-    try {
-      const userGroup = await UserGroup.findById(id);
-      const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
-      const serialized = userGroupRelations.map(relation => serializeUserGroupRelationSecurely(relation));
-      return res.apiv3({ userGroupRelations: serialized });
-    }
-    catch (err) {
-      const msg = `Error occurred in fetching user group relations for group: ${id}`;
-      logger.error(msg, err);
-      return res.apiv3Err(new ErrorV3(msg, 'user-group-user-group-relation-list-fetch-failed'));
-    }
-  });
+  router.get('/:id/user-group-relations',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+    async(req, res) => {
+      const { id } = req.params;
+
+      try {
+        const userGroup = await UserGroup.findById(id);
+        const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
+        const serialized = userGroupRelations.map(relation => serializeUserGroupRelationSecurely(relation));
+        return res.apiv3({ userGroupRelations: serialized });
+      }
+      catch (err) {
+        const msg = `Error occurred in fetching user group relations for group: ${id}`;
+        logger.error(msg, err);
+        return res.apiv3Err(new ErrorV3(msg, 'user-group-user-group-relation-list-fetch-failed'));
+      }
+    });
 
 
   /**
@@ -962,40 +1000,43 @@ module.exports = (crowi) => {
    *                        type: object
    *                      description: page objects
    */
-  router.get('/:id/pages', loginRequiredStrictly, adminRequired, validator.pages.get, apiV3FormValidator, async(req, res) => {
-    const { id } = req.params;
-    const { limit, offset } = req.query;
-
-    try {
-      const { docs, totalDocs } = await Page.paginate({
-        grant: Page.GRANT_USER_GROUP,
-        grantedGroups: {
-          $elemMatch: {
-            item: id,
+  router.get('/:id/pages',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+    validator.pages.get, apiV3FormValidator,
+    async(req, res) => {
+      const { id } = req.params;
+      const { limit, offset } = req.query;
+
+      try {
+        const { docs, totalDocs } = await Page.paginate({
+          grant: Page.GRANT_USER_GROUP,
+          grantedGroups: {
+            $elemMatch: {
+              item: id,
+            },
           },
-        },
-      }, {
-        offset,
-        limit,
-        populate: 'lastUpdateUser',
-      });
-
-      const current = offset / limit + 1;
-
-      const pages = docs.map((doc) => {
-        doc.lastUpdateUser = serializeUserSecurely(doc.lastUpdateUser);
-        return doc;
-      });
-
-      // TODO: create a common moudule for paginated response
-      return res.apiv3({ total: totalDocs, current, pages });
-    }
-    catch (err) {
-      const msg = `Error occurred in fetching pages for group: ${id}`;
-      logger.error(msg, err);
-      return res.apiv3Err(new ErrorV3(msg, 'user-group-page-list-fetch-failed'));
-    }
-  });
+        }, {
+          offset,
+          limit,
+          populate: 'lastUpdateUser',
+        });
+
+        const current = offset / limit + 1;
+
+        const pages = docs.map((doc) => {
+          doc.lastUpdateUser = serializeUserSecurely(doc.lastUpdateUser);
+          return doc;
+        });
+
+        // TODO: create a common moudule for paginated response
+        return res.apiv3({ total: totalDocs, current, pages });
+      }
+      catch (err) {
+        const msg = `Error occurred in fetching pages for group: ${id}`;
+        logger.error(msg, err);
+        return res.apiv3Err(new ErrorV3(msg, 'user-group-page-list-fetch-failed'));
+      }
+    });
 
   return router;
 };

+ 2 - 1
apps/app/src/server/routes/apiv3/user/get-related-groups.ts

@@ -2,6 +2,7 @@ import type { IUserHasId } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
 
+import { SCOPE } from '~/interfaces/scope';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import loggerFactory from '~/utils/logger';
@@ -20,7 +21,7 @@ export const getRelatedGroupsHandlerFactory: GetRelatedGroupsHandlerFactory = (c
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
 
   return [
-    accessTokenParser, loginRequiredStrictly,
+    accessTokenParser([SCOPE.READ.USER_SETTINGS.INFO]), loginRequiredStrictly,
     async(req: Req, res: ApiV3Response) => {
       try {
         const relatedGroups = await crowi.pageGrantService?.getUserRelatedGroups(req.user);

+ 218 - 203
apps/app/src/server/routes/apiv3/users.js

@@ -1,4 +1,3 @@
-
 import path from 'path';
 
 import { ErrorV3 } from '@growi/core/dist/models';
@@ -11,6 +10,7 @@ import { isEmail } from 'validator';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import { deleteUserAiAssistant } from '~/features/openai/server/services/delete-ai-assistant';
 import { SupportedAction } from '~/interfaces/activity';
+import { SCOPE } from '~/interfaces/scope';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import Activity from '~/server/models/activity';
 import ExternalAccount from '~/server/models/external-account';
@@ -251,7 +251,7 @@ module.exports = (crowi) => {
    *                      $ref: '#/components/schemas/PaginateResult'
    */
 
-  router.get('/', accessTokenParser(), loginRequired, validator.statusList, apiV3FormValidator, async(req, res) => {
+  router.get('/', accessTokenParser([SCOPE.READ.USER_SETTINGS.INFO]), loginRequired, validator.statusList, apiV3FormValidator, async(req, res) => {
 
     const page = parseInt(req.query.page) || 1;
     // status
@@ -356,42 +356,43 @@ module.exports = (crowi) => {
    *                    paginateResult:
    *                      $ref: '#/components/schemas/PaginateResult'
    */
-  router.get('/:id/recent', accessTokenParser(), loginRequired, validator.recentCreatedByUser, apiV3FormValidator, async(req, res) => {
-    const { id } = req.params;
+  router.get('/:id/recent', accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequired,
+    validator.recentCreatedByUser, apiV3FormValidator, async(req, res) => {
+      const { id } = req.params;
 
-    let user;
+      let user;
 
-    try {
-      user = await User.findById(id);
-    }
-    catch (err) {
-      const msg = 'Error occurred in find user';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'retrieve-recent-created-pages-failed'), 500);
-    }
+      try {
+        user = await User.findById(id);
+      }
+      catch (err) {
+        const msg = 'Error occurred in find user';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'retrieve-recent-created-pages-failed'), 500);
+      }
 
-    if (user == null) {
-      return res.apiv3Err(new ErrorV3('find-user-is-not-found'));
-    }
+      if (user == null) {
+        return res.apiv3Err(new ErrorV3('find-user-is-not-found'));
+      }
 
-    const limit = parseInt(req.query.limit) || await configManager.getConfig('customize:showPageLimitationM') || 30;
-    const page = req.query.page;
-    const offset = (page - 1) * limit;
-    const queryOptions = { offset, limit };
+      const limit = parseInt(req.query.limit) || await configManager.getConfig('customize:showPageLimitationM') || 30;
+      const page = req.query.page;
+      const offset = (page - 1) * limit;
+      const queryOptions = { offset, limit };
 
-    try {
-      const result = await Page.findListByCreator(user, req.user, queryOptions);
+      try {
+        const result = await Page.findListByCreator(user, req.user, queryOptions);
 
-      result.pages = result.pages.map(page => serializePageSecurely(page));
+        result.pages = result.pages.map(page => serializePageSecurely(page));
 
-      return res.apiv3(result);
-    }
-    catch (err) {
-      const msg = 'Error occurred in retrieve recent created pages for user';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'retrieve-recent-created-pages-failed'), 500);
-    }
-  });
+        return res.apiv3(result);
+      }
+      catch (err) {
+        const msg = 'Error occurred in retrieve recent created pages for user';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'retrieve-recent-created-pages-failed'), 500);
+      }
+    });
 
   validator.inviteEmail = [
     // isEmail prevents line breaks, so use isString
@@ -453,35 +454,37 @@ module.exports = (crowi) => {
    *                          type: string
    *                          description: reason for failure
    */
-  router.post('/invite', loginRequiredStrictly, adminRequired, addActivity, validator.inviteEmail, apiV3FormValidator, async(req, res) => {
-
-    // Delete duplicate email addresses
-    const emailList = Array.from(new Set(req.body.shapedEmailList));
-    let failedEmailList = [];
-
-    // Create users
-    const createUser = await User.createUsersByEmailList(emailList);
-    if (createUser.failedToCreateUserEmailList.length > 0) {
-      failedEmailList = failedEmailList.concat(createUser.failedToCreateUserEmailList);
-    }
+  router.post('/invite', accessTokenParser([SCOPE.WRITE.ADMIN.USER_MANAGEMENT]), loginRequiredStrictly, adminRequired, addActivity,
+    validator.inviteEmail, apiV3FormValidator,
+    async(req, res) => {
+
+      // Delete duplicate email addresses
+      const emailList = Array.from(new Set(req.body.shapedEmailList));
+      let failedEmailList = [];
+
+      // Create users
+      const createUser = await User.createUsersByEmailList(emailList);
+      if (createUser.failedToCreateUserEmailList.length > 0) {
+        failedEmailList = failedEmailList.concat(createUser.failedToCreateUserEmailList);
+      }
 
-    // Send email
-    if (req.body.sendEmail) {
-      const sendEmail = await sendEmailByUserList(createUser.createdUserList);
-      if (sendEmail.failedToSendEmailList.length > 0) {
-        failedEmailList = failedEmailList.concat(sendEmail.failedToSendEmailList);
+      // Send email
+      if (req.body.sendEmail) {
+        const sendEmail = await sendEmailByUserList(createUser.createdUserList);
+        if (sendEmail.failedToSendEmailList.length > 0) {
+          failedEmailList = failedEmailList.concat(sendEmail.failedToSendEmailList);
+        }
       }
-    }
 
-    const parameters = { action: SupportedAction.ACTION_ADMIN_USERS_INVITE };
-    activityEvent.emit('update', res.locals.activity._id, parameters);
+      const parameters = { action: SupportedAction.ACTION_ADMIN_USERS_INVITE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
 
-    return res.apiv3({
-      createdUserList: createUser.createdUserList,
-      existingEmailList: createUser.existingEmailList,
-      failedEmailList,
-    }, 201);
-  });
+      return res.apiv3({
+        createdUserList: createUser.createdUserList,
+        existingEmailList: createUser.existingEmailList,
+        failedEmailList,
+      }, 201);
+    });
 
   /**
    * @swagger
@@ -513,7 +516,7 @@ module.exports = (crowi) => {
    *                      $ref: '#/components/schemas/User'
    *                      description: data of admin user
    */
-  router.put('/:id/grant-admin', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
+  router.put('/:id/grant-admin', accessTokenParser([SCOPE.WRITE.ADMIN.USER_MANAGEMENT]), loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
     const { id } = req.params;
 
     try {
@@ -562,24 +565,26 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: data of revoked admin user
    */
-  router.put('/:id/revoke-admin', loginRequiredStrictly, adminRequired, certifyUserOperationOtherThenYourOwn, addActivity, async(req, res) => {
-    const { id } = req.params;
+  router.put('/:id/revoke-admin', accessTokenParser([SCOPE.WRITE.ADMIN.USER_MANAGEMENT]),
+    loginRequiredStrictly, adminRequired, certifyUserOperationOtherThenYourOwn, addActivity,
+    async(req, res) => {
+      const { id } = req.params;
 
-    try {
-      const userData = await User.findById(id);
-      await userData.revokeAdmin();
+      try {
+        const userData = await User.findById(id);
+        await userData.revokeAdmin();
 
-      const serializedUserData = serializeUserSecurely(userData);
+        const serializedUserData = serializeUserSecurely(userData);
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_REVOKE_ADMIN });
+        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_REVOKE_ADMIN });
 
-      return res.apiv3({ userData: serializedUserData });
-    }
-    catch (err) {
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(err));
-    }
-  });
+        return res.apiv3({ userData: serializedUserData });
+      }
+      catch (err) {
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(err));
+      }
+    });
 
   /**
    * @swagger
@@ -611,29 +616,30 @@ module.exports = (crowi) => {
    *                      $ref: '#/components/schemas/User'
    *                      description: data of grant read only
    */
-  router.put('/:id/grant-read-only', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
-    const { id } = req.params;
+  router.put('/:id/grant-read-only', accessTokenParser([SCOPE.WRITE.ADMIN.USER_MANAGEMENT]), loginRequiredStrictly, adminRequired, addActivity,
+    async(req, res) => {
+      const { id } = req.params;
 
-    try {
-      const userData = await User.findById(id);
+      try {
+        const userData = await User.findById(id);
 
-      if (userData == null) {
-        return res.apiv3Err(new ErrorV3('User not found'), 404);
-      }
+        if (userData == null) {
+          return res.apiv3Err(new ErrorV3('User not found'), 404);
+        }
 
-      await userData.grantReadOnly();
+        await userData.grantReadOnly();
 
-      const serializedUserData = serializeUserSecurely(userData);
+        const serializedUserData = serializeUserSecurely(userData);
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_GRANT_READ_ONLY });
+        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_GRANT_READ_ONLY });
 
-      return res.apiv3({ userData: serializedUserData });
-    }
-    catch (err) {
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(err));
-    }
-  });
+        return res.apiv3({ userData: serializedUserData });
+      }
+      catch (err) {
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(err));
+      }
+    });
 
   /**
    * @swagger
@@ -665,29 +671,30 @@ module.exports = (crowi) => {
    *                      $ref: '#/components/schemas/User'
    *                      description: data of revoke read only
    */
-  router.put('/:id/revoke-read-only', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
-    const { id } = req.params;
+  router.put('/:id/revoke-read-only', accessTokenParser([SCOPE.WRITE.ADMIN.USER_MANAGEMENT]), loginRequiredStrictly, adminRequired, addActivity,
+    async(req, res) => {
+      const { id } = req.params;
 
-    try {
-      const userData = await User.findById(id);
+      try {
+        const userData = await User.findById(id);
 
-      if (userData == null) {
-        return res.apiv3Err(new ErrorV3('User not found'), 404);
-      }
+        if (userData == null) {
+          return res.apiv3Err(new ErrorV3('User not found'), 404);
+        }
 
-      await userData.revokeReadOnly();
+        await userData.revokeReadOnly();
 
-      const serializedUserData = serializeUserSecurely(userData);
+        const serializedUserData = serializeUserSecurely(userData);
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_REVOKE_READ_ONLY });
+        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_REVOKE_READ_ONLY });
 
-      return res.apiv3({ userData: serializedUserData });
-    }
-    catch (err) {
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(err));
-    }
-  });
+        return res.apiv3({ userData: serializedUserData });
+      }
+      catch (err) {
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(err));
+      }
+    });
 
   /**
    * @swagger
@@ -719,7 +726,7 @@ module.exports = (crowi) => {
    *                      $ref: '#/components/schemas/User'
    *                      description: data of activate user
    */
-  router.put('/:id/activate', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
+  router.put('/:id/activate', accessTokenParser([SCOPE.WRITE.ADMIN.USER_MANAGEMENT]), loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
     // check user upper limit
     const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();
     if (isUserCountExceedsUpperLimit) {
@@ -775,24 +782,26 @@ module.exports = (crowi) => {
    *                      $ref: '#/components/schemas/User'
    *                      description: data of deactivate user
    */
-  router.put('/:id/deactivate', loginRequiredStrictly, adminRequired, certifyUserOperationOtherThenYourOwn, addActivity, async(req, res) => {
-    const { id } = req.params;
+  router.put('/:id/deactivate', accessTokenParser([SCOPE.WRITE.ADMIN.USER_MANAGEMENT]),
+    loginRequiredStrictly, adminRequired, certifyUserOperationOtherThenYourOwn, addActivity,
+    async(req, res) => {
+      const { id } = req.params;
 
-    try {
-      const userData = await User.findById(id);
-      await userData.statusSuspend();
+      try {
+        const userData = await User.findById(id);
+        await userData.statusSuspend();
 
-      const serializedUserData = serializeUserSecurely(userData);
+        const serializedUserData = serializeUserSecurely(userData);
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_DEACTIVATE });
+        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_DEACTIVATE });
 
-      return res.apiv3({ userData: serializedUserData });
-    }
-    catch (err) {
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(err));
-    }
-  });
+        return res.apiv3({ userData: serializedUserData });
+      }
+      catch (err) {
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(err));
+      }
+    });
 
   /**
    * @swagger
@@ -824,39 +833,41 @@ module.exports = (crowi) => {
    *                      $ref: '#/components/schemas/User'
    *                      description: data of deleted user
    */
-  router.delete('/:id/remove', loginRequiredStrictly, adminRequired, certifyUserOperationOtherThenYourOwn, addActivity, async(req, res) => {
-    const { id } = req.params;
-    const isUsersHomepageDeletionEnabled = configManager.getConfig('security:user-homepage-deletion:isEnabled');
-    const isForceDeleteUserHomepageOnUserDeletion = configManager.getConfig('security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion');
+  router.delete('/:id/remove', accessTokenParser([SCOPE.WRITE.ADMIN.USER_MANAGEMENT]),
+    loginRequiredStrictly, adminRequired, certifyUserOperationOtherThenYourOwn, addActivity,
+    async(req, res) => {
+      const { id } = req.params;
+      const isUsersHomepageDeletionEnabled = configManager.getConfig('security:user-homepage-deletion:isEnabled');
+      const isForceDeleteUserHomepageOnUserDeletion = configManager.getConfig('security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion');
 
-    try {
-      const user = await User.findById(id);
-      // !! DO NOT MOVE homepagePath FROM THIS POSITION !! -- 05.31.2023
-      // catch username before delete user because username will be change to deleted_at_*
-      const homepagePath = userHomepagePath(user);
+      try {
+        const user = await User.findById(id);
+        // !! DO NOT MOVE homepagePath FROM THIS POSITION !! -- 05.31.2023
+        // catch username before delete user because username will be change to deleted_at_*
+        const homepagePath = userHomepagePath(user);
 
-      await UserGroupRelation.remove({ relatedUser: user });
-      await ExternalUserGroupRelation.remove({ relatedUser: user });
-      await user.statusDelete();
-      await ExternalAccount.remove({ user });
+        await UserGroupRelation.remove({ relatedUser: user });
+        await ExternalUserGroupRelation.remove({ relatedUser: user });
+        await user.statusDelete();
+        await ExternalAccount.remove({ user });
 
-      deleteUserAiAssistant(user);
+        deleteUserAiAssistant(user);
 
-      const serializedUser = serializeUserSecurely(user);
+        const serializedUser = serializeUserSecurely(user);
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_REMOVE });
+        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_REMOVE });
 
-      if (isUsersHomepageDeletionEnabled && isForceDeleteUserHomepageOnUserDeletion) {
-        deleteCompletelyUserHomeBySystem(homepagePath, crowi.pageService);
-      }
+        if (isUsersHomepageDeletionEnabled && isForceDeleteUserHomepageOnUserDeletion) {
+          deleteCompletelyUserHomeBySystem(homepagePath, crowi.pageService);
+        }
 
-      return res.apiv3({ user: serializedUser });
-    }
-    catch (err) {
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(err));
-    }
-  });
+        return res.apiv3({ user: serializedUser });
+      }
+      catch (err) {
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(err));
+      }
+    });
 
   /**
    * @swagger
@@ -886,7 +897,7 @@ module.exports = (crowi) => {
    *                    paginateResult:
    *                      $ref: '#/components/schemas/PaginateResult'
    */
-  router.get('/external-accounts/', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.get('/external-accounts/', accessTokenParser([SCOPE.READ.USER_SETTINGS.EXTERNAL_ACCOUNT]), loginRequiredStrictly, adminRequired, async(req, res) => {
     const page = parseInt(req.query.page) || 1;
     try {
       const paginateResult = await ExternalAccount.findAllWithPagination({ page });
@@ -929,20 +940,22 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: A result of `ExtenralAccount.findByIdAndRemove`
    */
-  router.delete('/external-accounts/:id/remove', loginRequiredStrictly, adminRequired, apiV3FormValidator, async(req, res) => {
-    const { id } = req.params;
+  router.delete('/external-accounts/:id/remove', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.EXTERNAL_ACCOUNT]),
+    loginRequiredStrictly, adminRequired, apiV3FormValidator,
+    async(req, res) => {
+      const { id } = req.params;
 
-    try {
-      const externalAccount = await ExternalAccount.findByIdAndRemove(id);
+      try {
+        const externalAccount = await ExternalAccount.findByIdAndRemove(id);
 
-      return res.apiv3({ externalAccount });
-    }
-    catch (err) {
-      const msg = 'Error occurred in deleting a external account  ';
-      logger.error(msg, err);
-      return res.apiv3Err(new ErrorV3(msg + err.message, 'extenral-account-delete-failed'));
-    }
-  });
+        return res.apiv3({ externalAccount });
+      }
+      catch (err) {
+        const msg = 'Error occurred in deleting a external account  ';
+        logger.error(msg, err);
+        return res.apiv3Err(new ErrorV3(msg + err.message, 'extenral-account-delete-failed'));
+      }
+    });
 
   /**
    * @swagger
@@ -975,7 +988,7 @@ module.exports = (crowi) => {
    *                  type: object
    *                  description: success creating imageUrlCached
    */
-  router.put('/update.imageUrlCache', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.put('/update.imageUrlCache', accessTokenParser([SCOPE.WRITE.ADMIN.USER_MANAGEMENT]), loginRequiredStrictly, adminRequired, async(req, res) => {
     try {
       const userIds = req.body.userIds;
       const users = await User.find({ _id: { $in: userIds }, imageUrlCached: null });
@@ -1033,7 +1046,7 @@ module.exports = (crowi) => {
    *                  user:
    *                    $ref: '#/components/schemas/User'
    */
-  router.put('/reset-password', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
+  router.put('/reset-password', accessTokenParser([SCOPE.WRITE.ADMIN.USER_MANAGEMENT]), loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
     const { id } = req.body;
 
     try {
@@ -1076,28 +1089,29 @@ module.exports = (crowi) => {
    *          200:
    *            description: success send new password email
    */
-  router.put('/reset-password-email', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
-    const { id } = req.body;
+  router.put('/reset-password-email', accessTokenParser([SCOPE.WRITE.ADMIN.USER_MANAGEMENT]), loginRequiredStrictly, adminRequired, addActivity,
+    async(req, res) => {
+      const { id } = req.body;
 
-    try {
-      const user = await User.findById(id);
-      if (user == null) {
-        throw new Error('User not found');
-      }
-      const userInfo = {
-        email: user.email,
-        password: req.body.newPassword,
-      };
+      try {
+        const user = await User.findById(id);
+        if (user == null) {
+          throw new Error('User not found');
+        }
+        const userInfo = {
+          email: user.email,
+          password: req.body.newPassword,
+        };
 
-      await sendEmailByUser(userInfo);
-      return res.apiv3();
-    }
-    catch (err) {
-      const msg = err.message;
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg));
-    }
-  });
+        await sendEmailByUser(userInfo);
+        return res.apiv3();
+      }
+      catch (err) {
+        const msg = err.message;
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg));
+      }
+    });
 
   /**
    * @swagger
@@ -1135,29 +1149,30 @@ module.exports = (crowi) => {
    *                        reason:
    *                          type: string
    */
-  router.put('/send-invitation-email', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
-    const { id } = req.body;
+  router.put('/send-invitation-email', accessTokenParser([SCOPE.WRITE.ADMIN.USER_MANAGEMENT]), loginRequiredStrictly, adminRequired, addActivity,
+    async(req, res) => {
+      const { id } = req.body;
 
-    try {
-      const user = await User.findById(id);
-      const newPassword = await User.resetPasswordByRandomString(id);
-      const userList = [{
-        email: user.email,
-        password: newPassword,
-        user: { id },
-      }];
-      const sendEmail = await sendEmailByUserList(userList);
-      // return null if absent
+      try {
+        const user = await User.findById(id);
+        const newPassword = await User.resetPasswordByRandomString(id);
+        const userList = [{
+          email: user.email,
+          password: newPassword,
+          user: { id },
+        }];
+        const sendEmail = await sendEmailByUserList(userList);
+        // return null if absent
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_SEND_INVITATION_EMAIL });
+        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_SEND_INVITATION_EMAIL });
 
-      return res.apiv3({ failedToSendEmail: sendEmail.failedToSendEmailList[0] });
-    }
-    catch (err) {
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(err));
-    }
-  });
+        return res.apiv3({ failedToSendEmail: sendEmail.failedToSendEmailList[0] });
+      }
+      catch (err) {
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(err));
+      }
+    });
 
   /**
    * @swagger
@@ -1193,7 +1208,7 @@ module.exports = (crowi) => {
    *            500:
    *              $ref: '#/components/responses/500'
    */
-  router.get('/list', accessTokenParser(), loginRequired, async(req, res) => {
+  router.get('/list', accessTokenParser([SCOPE.READ.USER_SETTINGS.INFO]), loginRequired, async(req, res) => {
     const userIds = req.query.userIds ?? null;
 
     let userFetcher;
@@ -1297,7 +1312,7 @@ module.exports = (crowi) => {
     *                        items:
     *                          type: string
     */
-  router.get('/usernames', accessTokenParser(), loginRequired, validator.usernames, apiV3FormValidator, async(req, res) => {
+  router.get('/usernames', accessTokenParser([SCOPE.READ.USER_SETTINGS.INFO]), loginRequired, validator.usernames, apiV3FormValidator, async(req, res) => {
     const q = req.query.q;
     const offset = +req.query.offset || 0;
     const limit = +req.query.limit || 10;

+ 3 - 1
apps/app/src/server/routes/attachment/get-brand-logo.ts

@@ -4,6 +4,8 @@ import type {
 } from 'express';
 
 import type { CrowiRequest } from '~/interfaces/crowi-request';
+import { SCOPE } from '~/interfaces/scope';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import loggerFactory from '~/utils/logger';
 
 import type Crowi from '../../crowi';
@@ -25,7 +27,7 @@ export const getBrandLogoRouterFactory = (crowi: Crowi): Router => {
 
   const router = express.Router();
 
-  router.get('/brand-logo', certifyBrandLogo, loginRequired, async(req: CrowiRequest, res: Response) => {
+  router.get('/brand-logo', certifyBrandLogo, accessTokenParser([SCOPE.READ.FEATURES.ATTACHMENT]), loginRequired, async(req: CrowiRequest, res: Response) => {
     const brandLogoAttachment = await Attachment.findOne({ attachmentType: AttachmentType.BRAND_LOGO });
 
     if (brandLogoAttachment == null) {

+ 2 - 1
apps/app/src/server/routes/attachment/get.ts

@@ -174,7 +174,8 @@ export const getRouterFactory = (crowi: Crowi): Router => {
 
   // note: retrieveAttachmentFromIdParam requires `req.params.id`
   router.get<{ id: string }>('/:id([0-9a-z]{24})',
-    certifySharedPageAttachmentMiddleware, loginRequired,
+    certifySharedPageAttachmentMiddleware,
+    loginRequired,
     retrieveAttachmentFromIdParam,
 
     (req: GetRequest, res: GetResponse) => {

+ 35 - 32
apps/app/src/server/routes/index.js

@@ -2,6 +2,7 @@ import csrf from 'csurf';
 import express from 'express';
 
 import { middlewareFactory as rateLimiterFactory } from '~/features/rate-limiter';
+import { SCOPE } from '~/interfaces/scope';
 
 import { accessTokenParser } from '../middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '../middlewares/add-activity';
@@ -21,6 +22,7 @@ import * as forgotPassword from './forgot-password';
 import nextFactory from './next';
 import * as userActivation from './user-activation';
 
+
 const multer = require('multer');
 const autoReap = require('multer-autoreap');
 
@@ -69,7 +71,7 @@ module.exports = function(crowi, app) {
 
   app.get('/_next/*'                  , next.delegateToNext);
 
-  app.get('/'                         , applicationInstalled, unavailableWhenMaintenanceMode, loginRequired, autoReconnectToSearch, next.delegateToNext);
+  app.get('/'                         ,  applicationInstalled, unavailableWhenMaintenanceMode, loginRequired, autoReconnectToSearch, next.delegateToNext);
 
   app.get('/login/error/:reason'      , applicationInstalled, next.delegateToNext);
   app.get('/login'                    , applicationInstalled, login.preLogin, next.delegateToNext);
@@ -77,8 +79,9 @@ module.exports = function(crowi, app) {
   // app.post('/login'                   , applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation, csrfProtection,  addActivity, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
 
   // NOTE: get method "/admin/export/:fileName" should be loaded before "/admin/*"
-  app.get('/admin/export/:fileName'   , loginRequiredStrictly , adminRequired ,admin.export.api.validators.export.download(), admin.export.download);
+  app.get('/admin/export/:fileName'   , accessTokenParser([SCOPE.READ.ADMIN.EXPORT_DATA]), loginRequiredStrictly , adminRequired ,admin.export.api.validators.export.download(), admin.export.download);
 
+  // TODO: If you want to use accessTokenParser, you need to add scope ANY e.g. accessTokenParser([SCOPE.READ.ADMIN.ANY])
   app.get('/admin/*'                  , applicationInstalled, loginRequiredStrictly , adminRequired , next.delegateToNext);
   app.get('/admin'                    , applicationInstalled, loginRequiredStrictly , adminRequired , next.delegateToNext);
 
@@ -101,12 +104,12 @@ module.exports = function(crowi, app) {
   app.post('/_api/login/testLdap'    , loginRequiredStrictly , loginFormValidator.loginRules() , loginFormValidator.loginValidation , loginPassport.testLdapCredentials);
 
   // importer management for admin
-  app.post('/_api/admin/settings/importerEsa'   , loginRequiredStrictly , adminRequired , csrfProtection, addActivity, admin.importer.api.validators.importer.esa(),admin.api.importerSettingEsa);
-  app.post('/_api/admin/settings/importerQiita' , loginRequiredStrictly , adminRequired , csrfProtection, addActivity, admin.importer.api.validators.importer.qiita(), admin.api.importerSettingQiita);
-  app.post('/_api/admin/import/esa'             , loginRequiredStrictly , adminRequired , csrfProtection, addActivity, admin.api.importDataFromEsa);
-  app.post('/_api/admin/import/testEsaAPI'      , loginRequiredStrictly , adminRequired , csrfProtection, addActivity, admin.api.testEsaAPI);
-  app.post('/_api/admin/import/qiita'           , loginRequiredStrictly , adminRequired , csrfProtection, addActivity, admin.api.importDataFromQiita);
-  app.post('/_api/admin/import/testQiitaAPI'    , loginRequiredStrictly , adminRequired , csrfProtection, addActivity, admin.api.testQiitaAPI);
+  app.post('/_api/admin/settings/importerEsa'   , accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA]), loginRequiredStrictly , adminRequired , csrfProtection, addActivity, admin.importer.api.validators.importer.esa(),admin.api.importerSettingEsa);
+  app.post('/_api/admin/settings/importerQiita' , accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA]), loginRequiredStrictly , adminRequired , csrfProtection, addActivity, admin.importer.api.validators.importer.qiita(), admin.api.importerSettingQiita);
+  app.post('/_api/admin/import/esa'             , accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA]), loginRequiredStrictly , adminRequired , csrfProtection, addActivity, admin.api.importDataFromEsa);
+  app.post('/_api/admin/import/testEsaAPI'      , accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA]), loginRequiredStrictly , adminRequired , csrfProtection, addActivity, admin.api.testEsaAPI);
+  app.post('/_api/admin/import/qiita'           , accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA]), loginRequiredStrictly , adminRequired , csrfProtection, addActivity, admin.api.importDataFromQiita);
+  app.post('/_api/admin/import/testQiitaAPI'    , accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA]), loginRequiredStrictly , adminRequired , csrfProtection, addActivity, admin.api.testQiitaAPI);
 
   // brand logo
   app.use('/attachment', attachment.getBrandLogoRouterFactory(crowi));
@@ -120,39 +123,39 @@ module.exports = function(crowi, app) {
 
   const apiV1Router = express.Router();
 
-  apiV1Router.get('/search'                        , accessTokenParser() , loginRequired , search.api.search);
+  apiV1Router.get('/search'                        , accessTokenParser([SCOPE.READ.FEATURES.PAGE]) , loginRequired , search.api.search);
 
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
-  apiV1Router.get('/pages.updatePost'    , accessTokenParser(), loginRequired, page.api.getUpdatePost);
-  apiV1Router.get('/pages.getPageTag'    , accessTokenParser() , loginRequired , page.api.getPageTag);
+  apiV1Router.get('/pages.updatePost'    , accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequired, page.api.getUpdatePost);
+  apiV1Router.get('/pages.getPageTag'    , accessTokenParser([SCOPE.READ.FEATURES.PAGE]) , loginRequired , page.api.getPageTag);
   // allow posting to guests because the client doesn't know whether the user logged in
-  apiV1Router.post('/pages.remove'       , loginRequiredStrictly , excludeReadOnlyUser, page.validator.remove, apiV1FormValidator, page.api.remove); // (Avoid from API Token)
-  apiV1Router.post('/pages.revertRemove' , loginRequiredStrictly , excludeReadOnlyUser, page.validator.revertRemove, apiV1FormValidator, page.api.revertRemove); // (Avoid from API Token)
-  apiV1Router.post('/pages.unlink'       , loginRequiredStrictly , excludeReadOnlyUser, page.api.unlink); // (Avoid from API Token)
-  apiV1Router.get('/tags.list'           , accessTokenParser(), loginRequired, tag.api.list);
-  apiV1Router.get('/tags.search'         , accessTokenParser(), loginRequired, tag.api.search);
-  apiV1Router.post('/tags.update'        , accessTokenParser(), loginRequiredStrictly, excludeReadOnlyUser, addActivity, tag.api.update);
-  apiV1Router.get('/comments.get'        , accessTokenParser() , loginRequired , comment.api.get);
-  apiV1Router.post('/comments.add'       , comment.api.validators.add(), accessTokenParser() , loginRequiredStrictly , excludeReadOnlyUserIfCommentNotAllowed, addActivity, comment.api.add);
-  apiV1Router.post('/comments.update'    , comment.api.validators.add(), accessTokenParser() , loginRequiredStrictly , excludeReadOnlyUserIfCommentNotAllowed, addActivity, comment.api.update);
-  apiV1Router.post('/comments.remove'    , accessTokenParser() , loginRequiredStrictly , excludeReadOnlyUserIfCommentNotAllowed, addActivity, comment.api.remove);
-
-  apiV1Router.post('/attachments.uploadProfileImage'   , uploads.single('file'), autoReap, accessTokenParser(), loginRequiredStrictly , excludeReadOnlyUser, attachmentApi.uploadProfileImage);
-  apiV1Router.post('/attachments.remove'               , accessTokenParser() , loginRequiredStrictly , excludeReadOnlyUser, addActivity ,attachmentApi.remove);
-  apiV1Router.post('/attachments.removeProfileImage'   , accessTokenParser() , loginRequiredStrictly , excludeReadOnlyUser, attachmentApi.removeProfileImage);
+  apiV1Router.post('/pages.remove'       , accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]), loginRequiredStrictly , excludeReadOnlyUser, page.validator.remove, apiV1FormValidator, page.api.remove); // (Avoid from API Token)
+  apiV1Router.post('/pages.revertRemove' , accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]), loginRequiredStrictly , excludeReadOnlyUser, page.validator.revertRemove, apiV1FormValidator, page.api.revertRemove); // (Avoid from API Token)
+  apiV1Router.post('/pages.unlink'       , accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]), loginRequiredStrictly , excludeReadOnlyUser, page.api.unlink); // (Avoid from API Token)
+  apiV1Router.get('/tags.list'           , accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequired, tag.api.list);
+  apiV1Router.get('/tags.search'         , accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequired, tag.api.search);
+  apiV1Router.post('/tags.update'        , accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]), loginRequiredStrictly, excludeReadOnlyUser, addActivity, tag.api.update);
+  apiV1Router.get('/comments.get'        , accessTokenParser([SCOPE.READ.FEATURES.PAGE]) , loginRequired , comment.api.get);
+  apiV1Router.post('/comments.add'       , accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]), comment.api.validators.add(), loginRequiredStrictly , excludeReadOnlyUserIfCommentNotAllowed, addActivity, comment.api.add);
+  apiV1Router.post('/comments.update'    , accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]), comment.api.validators.add(), loginRequiredStrictly , excludeReadOnlyUserIfCommentNotAllowed, addActivity, comment.api.update);
+  apiV1Router.post('/comments.remove'    , accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]), loginRequiredStrictly , excludeReadOnlyUserIfCommentNotAllowed, addActivity, comment.api.remove);
+
+  apiV1Router.post('/attachments.uploadProfileImage'   , accessTokenParser([SCOPE.WRITE.FEATURES.ATTACHMENT]), uploads.single('file'), autoReap, loginRequiredStrictly , excludeReadOnlyUser, attachmentApi.uploadProfileImage);
+  apiV1Router.post('/attachments.remove'               , accessTokenParser([SCOPE.WRITE.FEATURES.ATTACHMENT]), loginRequiredStrictly , excludeReadOnlyUser, addActivity ,attachmentApi.remove);
+  apiV1Router.post('/attachments.removeProfileImage'   , accessTokenParser([SCOPE.WRITE.FEATURES.ATTACHMENT]), loginRequiredStrictly , excludeReadOnlyUser, attachmentApi.removeProfileImage);
 
   // API v1
   app.use('/_api', unavailableWhenMaintenanceModeForApi, apiV1Router);
 
   app.use(unavailableWhenMaintenanceMode);
 
-  app.get('/me'                                   , loginRequiredStrictly, next.delegateToNext);
-  app.get('/me/*'                                 , loginRequiredStrictly, next.delegateToNext);
+  app.get('/me'                                   , accessTokenParser([SCOPE.READ.USER_SETTINGS.INFO]), loginRequiredStrictly, next.delegateToNext);
+  app.get('/me/*'                                 , accessTokenParser([SCOPE.READ.USER_SETTINGS.INFO]), loginRequiredStrictly, next.delegateToNext);
 
-  app.use('/attachment', attachment.getRouterFactory(crowi));
-  app.use('/download', attachment.downloadRouterFactory(crowi));
+  app.use('/attachment', accessTokenParser([SCOPE.READ.FEATURES.ATTACHMENT]), attachment.getRouterFactory(crowi));
+  app.use('/download', accessTokenParser([SCOPE.READ.FEATURES.ATTACHMENT]), attachment.downloadRouterFactory(crowi));
 
-  app.get('/_search'                            , loginRequired, next.delegateToNext);
+  app.get('/_search'                            , accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequired, next.delegateToNext);
 
   app.use('/forgot-password', express.Router()
     .use(forgotPassword.checkForgotPasswordEnabledMiddlewareFactory(crowi))
@@ -171,7 +174,7 @@ module.exports = function(crowi, app) {
 
   app.use('/ogp', express.Router().get('/:pageId([0-9a-z]{0,})', loginRequired, ogp.pageIdRequired, ogp.ogpValidator, ogp.renderOgp));
 
-  app.get('/*/$'                   , loginRequired, next.delegateToNext);
-  app.get('/*'                     , loginRequired, autoReconnectToSearch, next.delegateToNext);
+  app.get('/*/$'                   , accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequired, next.delegateToNext);
+  app.get('/*'                     , accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequired, autoReconnectToSearch, next.delegateToNext);
 
 };

+ 22 - 22
apps/app/src/server/util/scope-util.spec.ts

@@ -9,8 +9,8 @@ import {
 describe('scope-utils', () => {
   describe('isValidScope', () => {
     it('should return true for valid scopes', () => {
-      expect(isValidScope(SCOPE.READ.USER.API.API_TOKEN)).toBe(true);
-      expect(isValidScope(SCOPE.WRITE.USER.API.ACCESS_TOKEN)).toBe(true);
+      expect(isValidScope(SCOPE.READ.USER_SETTINGS.API.API_TOKEN)).toBe(true);
+      expect(isValidScope(SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN)).toBe(true);
       expect(isValidScope(SCOPE.READ.ADMIN.APP)).toBe(true);
     });
 
@@ -23,26 +23,26 @@ describe('scope-utils', () => {
 
   describe('hasAllScope', () => {
     it('should return true for scopes ending with *', () => {
-      expect(hasAllScope(SCOPE.READ.USER.API.ALL)).toBe(true);
+      expect(hasAllScope(SCOPE.READ.USER_SETTINGS.API.ALL)).toBe(true);
       expect(hasAllScope(SCOPE.WRITE.ADMIN.ALL)).toBe(true);
     });
 
     it('should return false for specific scopes', () => {
-      expect(hasAllScope(SCOPE.READ.USER.API.API_TOKEN)).toBe(false);
-      expect(hasAllScope(SCOPE.WRITE.USER.API.ACCESS_TOKEN)).toBe(false);
+      expect(hasAllScope(SCOPE.READ.USER_SETTINGS.API.API_TOKEN)).toBe(false);
+      expect(hasAllScope(SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN)).toBe(false);
     });
   });
 
   describe('extractAllScope', () => {
     it('should extract all specific scopes from ALL scope', () => {
-      const extracted = extractAllScope(SCOPE.READ.USER.API.ALL);
-      expect(extracted).toContain(SCOPE.READ.USER.API.API_TOKEN);
-      expect(extracted).toContain(SCOPE.READ.USER.API.ACCESS_TOKEN);
-      expect(extracted).not.toContain(SCOPE.READ.USER.API.ALL);
+      const extracted = extractAllScope(SCOPE.READ.USER_SETTINGS.API.ALL);
+      expect(extracted).toContain(SCOPE.READ.USER_SETTINGS.API.API_TOKEN);
+      expect(extracted).toContain(SCOPE.READ.USER_SETTINGS.API.ACCESS_TOKEN);
+      expect(extracted).not.toContain(SCOPE.READ.USER_SETTINGS.API.ALL);
     });
 
     it('should return array with single scope for specific scope', () => {
-      const scope = SCOPE.READ.USER.API.API_TOKEN;
+      const scope = SCOPE.READ.USER_SETTINGS.API.API_TOKEN;
       const extracted = extractAllScope(scope);
       expect(extracted).toEqual([scope]);
     });
@@ -54,32 +54,32 @@ describe('scope-utils', () => {
     });
 
     it('should extract all implied scopes including READ permission for WRITE scopes', () => {
-      const scopes = [SCOPE.WRITE.USER.API.ACCESS_TOKEN];
+      const scopes = [SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN];
       const extracted = extractScopes(scopes);
 
-      expect(extracted).toContain(SCOPE.WRITE.USER.API.ACCESS_TOKEN);
-      expect(extracted).toContain(SCOPE.READ.USER.API.ACCESS_TOKEN);
+      expect(extracted).toContain(SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN);
+      expect(extracted).toContain(SCOPE.READ.USER_SETTINGS.API.ACCESS_TOKEN);
     });
 
     it('should extract all specific scopes from ALL scope with implied permissions', () => {
-      const scopes = [SCOPE.WRITE.USER.API.ALL];
+      const scopes = [SCOPE.WRITE.USER_SETTINGS.API.ALL];
       const extracted = extractScopes(scopes);
 
       // Should include both WRITE and READ permissions for all specific scopes
-      expect(extracted).toContain(SCOPE.WRITE.USER.API.API_TOKEN);
-      expect(extracted).toContain(SCOPE.WRITE.USER.API.ACCESS_TOKEN);
-      expect(extracted).toContain(SCOPE.READ.USER.API.API_TOKEN);
-      expect(extracted).toContain(SCOPE.READ.USER.API.ACCESS_TOKEN);
+      expect(extracted).toContain(SCOPE.WRITE.USER_SETTINGS.API.API_TOKEN);
+      expect(extracted).toContain(SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN);
+      expect(extracted).toContain(SCOPE.READ.USER_SETTINGS.API.API_TOKEN);
+      expect(extracted).toContain(SCOPE.READ.USER_SETTINGS.API.ACCESS_TOKEN);
 
       // Should not include ALL scopes
-      expect(extracted).not.toContain(SCOPE.WRITE.USER.API.ALL);
-      expect(extracted).not.toContain(SCOPE.READ.USER.API.ALL);
+      expect(extracted).not.toContain(SCOPE.WRITE.USER_SETTINGS.API.ALL);
+      expect(extracted).not.toContain(SCOPE.READ.USER_SETTINGS.API.ALL);
     });
 
     it('should remove duplicate scopes', () => {
       const scopes = [
-        SCOPE.WRITE.USER.API.ACCESS_TOKEN,
-        SCOPE.READ.USER.API.ACCESS_TOKEN, // This is implied by WRITE
+        SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN,
+        SCOPE.READ.USER_SETTINGS.API.ACCESS_TOKEN, // This is implied by WRITE
       ];
       const extracted = extractScopes(scopes);
 

+ 2 - 2
apps/app/src/server/util/scope-utils.ts

@@ -20,7 +20,7 @@ export const hasAllScope = (scope: Scope): scope is Scope => {
 
 /**
  * Returns all values of the scope object
- * For example, SCOPE.READ.USER.API.ALL returns ['read:user:api:access_token', 'read:user:api:api_token']
+ * For example, SCOPE.READ.USER_SETTINGS.API.ALL returns ['read:user:api:access_token', 'read:user:api:api_token']
  */
 const getAllScopeValuesFromObj = (scopeObj: any): Scope[] => {
   const result: Scope[] = [];
@@ -78,7 +78,7 @@ export const extractAllScope = (scope: Scope): Scope[] => {
 /**
  * Extracts scopes from a given array of scopes
  * And delete all scopes
- * For example, [SCOPE.WRITE.USER.API.ALL] === ['write:user:api:all']
+ * For example, [SCOPE.WRITE.USER_SETTINGS.API.ALL] === ['write:user:api:all']
  * returns ['read:user:api:access_token',
  *          'read:user:api:api_token'
  *          'write:user:api:access_token',