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

feat: enhance access token parser with new scopes for attachment management

reiji-h 1 год назад
Родитель
Сommit
46b16475fe

+ 1 - 0
apps/app/src/interfaces/scope.ts

@@ -39,6 +39,7 @@ export const ORIGINAL_SCOPE_USER = {
     share_link: {},
     bookmark: {},
     questionnaire: {},
+    attachment: {},
   },
 } as const;
 

+ 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();
 

+ 112 - 104
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';
@@ -430,7 +431,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'),
@@ -527,37 +528,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
@@ -592,36 +595,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'));
-    }
-
-    const requestSiteUrlSettingParams = {
-      'app:siteUrl': pathUtils.removeTrailingSlash(req.body.siteUrl),
-    };
+  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');
+
+      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)
@@ -739,28 +743,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
@@ -782,7 +788,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 {
@@ -824,32 +830,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;
-
-    const requestSesSettingParams = {
-      'mail:from': req.body.fromAddress,
-      'mail:transmissionMethod': req.body.transmissionMethod,
-      'mail:sesAccessKeyId': req.body.sesAccessKeyId,
-      'mail:sesSecretAccessKey': req.body.sesSecretAccessKey,
-    };
+  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,
+      };
 
-    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
@@ -881,7 +889,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 = {
@@ -992,7 +1000,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 = {
@@ -1044,7 +1052,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'));
@@ -1099,7 +1107,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 {

+ 17 - 14
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,7 +199,7 @@ module.exports = (crowi) => {
    *                  type: object
    *                  $ref: '#/components/schemas/AttachmentPaginateResult'
    */
-  router.get('/list', accessTokenParser(), loginRequired, validator.retrieveAttachments, apiV3FormValidator, async(req, res) => {
+  router.get('/list', accessTokenParser([SCOPE.READ.BASE.ATTACHMENT]), 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;
@@ -272,17 +273,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.BASE.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 +341,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.BASE.ATTACHMENT]), loginRequiredStrictly, excludeReadOnlyUser,
     validator.retrieveAddAttachment, apiV3FormValidator, addActivity,
     async(req, res) => {
 
@@ -403,7 +405,8 @@ module.exports = (crowi) => {
    *            schema:
    *              type: string
    */
-  router.get('/:id', accessTokenParser(), certifySharedPageAttachmentMiddleware, loginRequired, validator.retrieveAttachment, apiV3FormValidator,
+  router.get('/:id', accessTokenParser([SCOPE.READ.BASE.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.BASE.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.BASE.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.BASE.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.BASE.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.BASE.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.BASE.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.BASE.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.BASE.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.BASE.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 });
 

+ 20 - 17
apps/app/src/server/routes/apiv3/export.js

@@ -1,4 +1,5 @@
 import { SupportedAction } from '~/interfaces/activity';
+import { SCOPE } from '~/interfaces/scope';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import loggerFactory from '~/utils/logger';
 
@@ -168,7 +169,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
@@ -209,7 +210,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;
@@ -259,25 +260,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;
 };

+ 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();
 

+ 16 - 14
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,7 +25,7 @@ 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.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!;
@@ -79,7 +80,7 @@ module.exports = (crowi) => {
     return res.apiv3(serializedPaginationResult);
   });
 
-  router.get('/status', accessTokenParser(), loginRequiredStrictly, async(req: CrowiRequest, res: ApiV3Response) => {
+  router.get('/status', accessTokenParser([SCOPE.READ.USER.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!;
@@ -93,7 +94,7 @@ module.exports = (crowi) => {
     }
   });
 
-  router.post('/open', accessTokenParser(), loginRequiredStrictly, async(req: CrowiRequest, res: ApiV3Response) => {
+  router.post('/open', accessTokenParser([SCOPE.WRITE.USER.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!;
@@ -110,22 +111,23 @@ module.exports = (crowi) => {
     }
   });
 
-  router.put('/all-statuses-open', accessTokenParser(), loginRequiredStrictly, addActivity, async(req: CrowiRequest, res: ApiV3Response) => {
+  router.put('/all-statuses-open', accessTokenParser([SCOPE.WRITE.USER.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;
 };

+ 210 - 171
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();
 
@@ -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]), loginRequiredStrictly, 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]), loginRequiredStrictly, 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]),
+    loginRequiredStrictly,
+    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]),
+    loginRequiredStrictly,
+    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]),
+    loginRequiredStrictly,
+    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]),
+    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,
+      };
 
-    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]),
+    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,
+      };
 
-    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]),
+    loginRequiredStrictly,
+    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]),
+    loginRequiredStrictly,
+    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;
 };

+ 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.BASE.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.BASE.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.BASE.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.BASE.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.BASE.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.BASE.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.BASE.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.BASE.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.BASE.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.BASE.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.BASE.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;
 };

+ 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.BASE.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.BASE.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;
 };

+ 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/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.BASE.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.BASE.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.BASE.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.BASE.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.BASE.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/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;
 };

+ 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.INFO]), loginRequiredStrictly,
     async(req: Req, res: ApiV3Response) => {
       try {
         const relatedGroups = await crowi.pageGrantService?.getUserRelatedGroups(req.user);