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

Merge remote-tracking branch 'origin/master' into support/use-jotai

Yuki Takei 8 месяцев назад
Родитель
Сommit
761dacdab7
100 измененных файлов с 6390 добавлено и 3697 удалено
  1. 3 3
      .devcontainer/compose.yml
  2. 10 0
      apps/app/.eslintrc.js
  3. 1 0
      apps/app/config/logger/config.dev.js
  4. 11 4
      apps/app/jest.config.js
  5. 18 15
      apps/app/next.config.js
  6. 1 4
      apps/app/nodemon.json
  7. 4 2
      apps/app/package.json
  8. 14 14
      apps/app/playwright.config.ts
  9. 24 0
      apps/app/playwright/60-home/home.spec.ts
  10. 92 0
      apps/app/public/static/locales/en_US/commons.json
  11. 32 3
      apps/app/public/static/locales/en_US/translation.json
  12. 92 0
      apps/app/public/static/locales/fr_FR/commons.json
  13. 32 3
      apps/app/public/static/locales/fr_FR/translation.json
  14. 93 0
      apps/app/public/static/locales/ja_JP/commons.json
  15. 32 3
      apps/app/public/static/locales/ja_JP/translation.json
  16. 92 0
      apps/app/public/static/locales/zh_CN/commons.json
  17. 34 4
      apps/app/public/static/locales/zh_CN/translation.json
  18. 0 119
      apps/app/resource/search/mappings-es7.json
  19. 129 0
      apps/app/resource/search/mappings-es7.ts
  20. 0 118
      apps/app/resource/search/mappings-es8-for-ci.json
  21. 0 119
      apps/app/resource/search/mappings-es8.json
  22. 128 0
      apps/app/resource/search/mappings-es8.ts
  23. 127 0
      apps/app/resource/search/mappings-es9-for-ci.ts
  24. 128 0
      apps/app/resource/search/mappings-es9.ts
  25. 143 0
      apps/app/src/client/components/Me/AccessTokenForm.tsx
  26. 105 0
      apps/app/src/client/components/Me/AccessTokenList.tsx
  27. 36 0
      apps/app/src/client/components/Me/AccessTokenScopeList.module.scss
  28. 89 0
      apps/app/src/client/components/Me/AccessTokenScopeList.tsx
  29. 41 0
      apps/app/src/client/components/Me/AccessTokenScopeSelect.tsx
  30. 135 0
      apps/app/src/client/components/Me/AccessTokenSettings.tsx
  31. 15 69
      apps/app/src/client/components/Me/ApiSettings.tsx
  32. 85 0
      apps/app/src/client/components/Me/ApiTokenSettings.tsx
  33. 137 0
      apps/app/src/client/util/scope-util.test.ts
  34. 143 0
      apps/app/src/client/util/scope-util.ts
  35. 27 8
      apps/app/src/components/PageView/PageView.tsx
  36. 25 18
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group-relation.ts
  37. 254 234
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  38. 56 49
      apps/app/src/features/growi-plugin/server/routes/apiv3/admin/index.ts
  39. 4 1
      apps/app/src/features/openai/server/routes/ai-assistant.ts
  40. 2 1
      apps/app/src/features/openai/server/routes/ai-assistants.ts
  41. 2 1
      apps/app/src/features/openai/server/routes/delete-ai-assistant.ts
  42. 2 1
      apps/app/src/features/openai/server/routes/delete-thread.ts
  43. 3 1
      apps/app/src/features/openai/server/routes/edit/index.ts
  44. 2 2
      apps/app/src/features/openai/server/routes/get-recent-threads.ts
  45. 2 1
      apps/app/src/features/openai/server/routes/get-threads.ts
  46. 2 1
      apps/app/src/features/openai/server/routes/message/get-messages.ts
  47. 2 1
      apps/app/src/features/openai/server/routes/message/post-message.ts
  48. 3 1
      apps/app/src/features/openai/server/routes/set-default-ai-assistant.ts
  49. 2 1
      apps/app/src/features/openai/server/routes/thread.ts
  50. 2 1
      apps/app/src/features/openai/server/routes/update-ai-assistant.ts
  51. 26 23
      apps/app/src/features/page-bulk-export/server/routes/apiv3/page-bulk-export.ts
  52. 1 11
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/index.ts
  53. 34 29
      apps/app/src/features/templates/server/routes/apiv3/index.ts
  54. 16 0
      apps/app/src/interfaces/access-token.ts
  55. 6 3
      apps/app/src/interfaces/activity.ts
  56. 3 1
      apps/app/src/server/crowi/index.js
  57. 1 0
      apps/app/src/server/crowi/setup-models.ts
  58. 213 0
      apps/app/src/server/middlewares/access-token-parser/access-token.integ.ts
  59. 53 0
      apps/app/src/server/middlewares/access-token-parser/access-token.ts
  60. 10 22
      apps/app/src/server/middlewares/access-token-parser/api-token.integ.ts
  61. 13 14
      apps/app/src/server/middlewares/access-token-parser/api-token.ts
  62. 31 1
      apps/app/src/server/middlewares/access-token-parser/index.ts
  63. 127 0
      apps/app/src/server/models/access-token.ts
  64. 5 5
      apps/app/src/server/models/user.js
  65. 81 78
      apps/app/src/server/routes/apiv3/activity.ts
  66. 3 1
      apps/app/src/server/routes/apiv3/admin-home.ts
  67. 269 257
      apps/app/src/server/routes/apiv3/app-settings.js
  68. 55 49
      apps/app/src/server/routes/apiv3/attachment.js
  69. 65 58
      apps/app/src/server/routes/apiv3/bookmark-folder.ts
  70. 118 112
      apps/app/src/server/routes/apiv3/bookmarks.js
  71. 264 241
      apps/app/src/server/routes/apiv3/customize-setting.js
  72. 23 17
      apps/app/src/server/routes/apiv3/export.js
  73. 57 56
      apps/app/src/server/routes/apiv3/g2g-transfer.ts
  74. 31 28
      apps/app/src/server/routes/apiv3/import.js
  75. 80 72
      apps/app/src/server/routes/apiv3/in-app-notification.ts
  76. 88 84
      apps/app/src/server/routes/apiv3/markdown-setting.js
  77. 4 1
      apps/app/src/server/routes/apiv3/mongo.js
  78. 211 172
      apps/app/src/server/routes/apiv3/notification-setting.js
  79. 113 107
      apps/app/src/server/routes/apiv3/page-listing.ts
  80. 2 1
      apps/app/src/server/routes/apiv3/page/check-page-existence.ts
  81. 2 1
      apps/app/src/server/routes/apiv3/page/create-page.ts
  82. 2 1
      apps/app/src/server/routes/apiv3/page/get-page-paths-with-descendant-count.ts
  83. 2 1
      apps/app/src/server/routes/apiv3/page/get-yjs-data.ts
  84. 208 197
      apps/app/src/server/routes/apiv3/page/index.ts
  85. 2 1
      apps/app/src/server/routes/apiv3/page/publish-page.ts
  86. 2 1
      apps/app/src/server/routes/apiv3/page/sync-latest-revision-body-to-yjs-draft.ts
  87. 2 1
      apps/app/src/server/routes/apiv3/page/unpublish-page.ts
  88. 2 1
      apps/app/src/server/routes/apiv3/page/update-page.ts
  89. 343 284
      apps/app/src/server/routes/apiv3/pages/index.js
  90. 65 0
      apps/app/src/server/routes/apiv3/personal-setting/delete-access-token.ts
  91. 51 0
      apps/app/src/server/routes/apiv3/personal-setting/delete-all-access-tokens.ts
  92. 107 0
      apps/app/src/server/routes/apiv3/personal-setting/generate-access-token.ts
  93. 46 0
      apps/app/src/server/routes/apiv3/personal-setting/get-access-tokens.ts
  94. 270 166
      apps/app/src/server/routes/apiv3/personal-setting/index.js
  95. 87 82
      apps/app/src/server/routes/apiv3/revisions.js
  96. 62 56
      apps/app/src/server/routes/apiv3/search.js
  97. 334 317
      apps/app/src/server/routes/apiv3/security-settings/index.js
  98. 114 89
      apps/app/src/server/routes/apiv3/share-links.js
  99. 29 26
      apps/app/src/server/routes/apiv3/slack-integration-legacy-settings.js
  100. 241 228
      apps/app/src/server/routes/apiv3/slack-integration-settings.js

+ 3 - 3
.devcontainer/compose.yml

@@ -26,10 +26,10 @@ services:
   #   cloned from https://github.com/weseek/growi-docker-compose.git
   #   cloned from https://github.com/weseek/growi-docker-compose.git
   elasticsearch:
   elasticsearch:
     build:
     build:
-      context: ../../growi-docker-compose/elasticsearch/v8
+      context: ../../growi-docker-compose/elasticsearch/v9
       dockerfile: ./Dockerfile
       dockerfile: ./Dockerfile
       args:
       args:
-        - version=8.7.0
+        - version=9.0.3
     restart: unless-stopped
     restart: unless-stopped
     ports:
     ports:
       - 9200
       - 9200
@@ -43,7 +43,7 @@ services:
         hard: -1
         hard: -1
     volumes:
     volumes:
       - /usr/share/elasticsearch/data
       - /usr/share/elasticsearch/data
-      - ../../growi-docker-compose/elasticsearch/v8/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml
+      - ../../growi-docker-compose/elasticsearch/v9/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml
 
 
 volumes:
 volumes:
   pnpm-store:
   pnpm-store:

+ 10 - 0
apps/app/.eslintrc.js

@@ -16,6 +16,16 @@ module.exports = {
     'src/linter-checker/**',
     'src/linter-checker/**',
     'tmp/**',
     'tmp/**',
     'next-env.d.ts',
     'next-env.d.ts',
+    'next.config.js',
+    'playwright.config.ts',
+    'test/integration/global-setup.js',
+    'test/integration/global-teardown.js',
+    'test/integration/setup-crowi.ts',
+    'test/integration/crowi/**',
+    'test/integration/middlewares/**',
+    'test/integration/migrations/**',
+    'test/integration/models/**',
+    'test/integration/setup.js',
   ],
   ],
   settings: {
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript
     // resolve path aliases by eslint-import-resolver-typescript

+ 1 - 0
apps/app/config/logger/config.dev.js

@@ -43,4 +43,5 @@ module.exports = {
   // 'growi:cli:ItemsTree': 'debug',
   // 'growi:cli:ItemsTree': 'debug',
   'growi:searchResultList': 'debug',
   'growi:searchResultList': 'debug',
   'growi:service:openai': 'debug',
   'growi:service:openai': 'debug',
+  'growi:middleware:access-token-parser:access-token': 'debug',
 };
 };

+ 11 - 4
apps/app/jest.config.js

@@ -22,9 +22,14 @@ module.exports = {
 
 
       rootDir: '.',
       rootDir: '.',
       roots: ['<rootDir>'],
       roots: ['<rootDir>'],
-      testMatch: ['<rootDir>/test/integration/**/*.test.ts', '<rootDir>/test/integration/**/*.test.js'],
+      testMatch: [
+        '<rootDir>/test/integration/**/*.test.ts',
+        '<rootDir>/test/integration/**/*.test.js',
+      ],
       // https://regex101.com/r/jTaxYS/1
       // https://regex101.com/r/jTaxYS/1
-      modulePathIgnorePatterns: ['<rootDir>/test/integration/*.*/v5(..*)*.[t|j]s'],
+      modulePathIgnorePatterns: [
+        '<rootDir>/test/integration/*.*/v5(..*)*.[t|j]s',
+      ],
       testEnvironment: 'node',
       testEnvironment: 'node',
       globalSetup: '<rootDir>/test/integration/global-setup.js',
       globalSetup: '<rootDir>/test/integration/global-setup.js',
       globalTeardown: '<rootDir>/test/integration/global-teardown.js',
       globalTeardown: '<rootDir>/test/integration/global-teardown.js',
@@ -43,7 +48,10 @@ module.exports = {
 
 
       rootDir: '.',
       rootDir: '.',
       roots: ['<rootDir>'],
       roots: ['<rootDir>'],
-      testMatch: ['<rootDir>/test/integration/**/v5.*.test.ts', '<rootDir>/test/integration/**/v5.*.test.js'],
+      testMatch: [
+        '<rootDir>/test/integration/**/v5.*.test.ts',
+        '<rootDir>/test/integration/**/v5.*.test.js',
+      ],
 
 
       testEnvironment: 'node',
       testEnvironment: 'node',
       globalSetup: '<rootDir>/test/integration/global-setup.js',
       globalSetup: '<rootDir>/test/integration/global-setup.js',
@@ -75,5 +83,4 @@ module.exports = {
     '/resource/',
     '/resource/',
     '/node_modules/',
     '/node_modules/',
   ],
   ],
-
 };
 };

+ 18 - 15
apps/app/next.config.js

@@ -8,8 +8,10 @@
 const path = require('path');
 const path = require('path');
 
 
 const { withSuperjson } = require('next-superjson');
 const { withSuperjson } = require('next-superjson');
-const { PHASE_PRODUCTION_BUILD, PHASE_PRODUCTION_SERVER } = require('next/constants');
-
+const {
+  PHASE_PRODUCTION_BUILD,
+  PHASE_PRODUCTION_SERVER,
+} = require('next/constants');
 
 
 const getTranspilePackages = () => {
 const getTranspilePackages = () => {
   const { listPrefixedPackages } = require('./src/utils/next.config.utils');
   const { listPrefixedPackages } = require('./src/utils/next.config.utils');
@@ -56,7 +58,14 @@ const getTranspilePackages = () => {
     'github-slugger',
     'github-slugger',
     'html-url-attributes',
     'html-url-attributes',
     'estree-util-is-identifier-name',
     'estree-util-is-identifier-name',
-    ...listPrefixedPackages(['remark-', 'rehype-', 'hast-', 'mdast-', 'micromark-', 'unist-']),
+    ...listPrefixedPackages([
+      'remark-',
+      'rehype-',
+      'hast-',
+      'mdast-',
+      'micromark-',
+      'unist-',
+    ]),
   ];
   ];
 
 
   // const eazyLogger = require('eazy-logger');
   // const eazyLogger = require('eazy-logger');
@@ -84,13 +93,11 @@ const optimizePackageImports = [
   '@growi/ui',
   '@growi/ui',
 ];
 ];
 
 
-module.exports = async(phase, { defaultConfig }) => {
-
+module.exports = async (phase, { defaultConfig }) => {
   const { i18n, localePath } = require('./config/next-i18next.config');
   const { i18n, localePath } = require('./config/next-i18next.config');
 
 
   /** @type {import('next').NextConfig} */
   /** @type {import('next').NextConfig} */
   const nextConfig = {
   const nextConfig = {
-
     reactStrictMode: true,
     reactStrictMode: true,
     poweredByHeader: false,
     poweredByHeader: false,
     pageExtensions: ['page.tsx', 'page.ts', 'page.jsx', 'page.js'],
     pageExtensions: ['page.tsx', 'page.ts', 'page.jsx', 'page.js'],
@@ -103,9 +110,8 @@ module.exports = async(phase, { defaultConfig }) => {
     typescript: {
     typescript: {
       tsconfigPath: 'tsconfig.build.client.json',
       tsconfigPath: 'tsconfig.build.client.json',
     },
     },
-    transpilePackages: phase !== PHASE_PRODUCTION_SERVER
-      ? getTranspilePackages()
-      : undefined,
+    transpilePackages:
+      phase !== PHASE_PRODUCTION_SERVER ? getTranspilePackages() : undefined,
     experimental: {
     experimental: {
       optimizePackageImports,
       optimizePackageImports,
     },
     },
@@ -150,7 +156,6 @@ module.exports = async(phase, { defaultConfig }) => {
 
 
       return config;
       return config;
     },
     },
-
   };
   };
 
 
   // production server
   // production server
@@ -159,11 +164,9 @@ module.exports = async(phase, { defaultConfig }) => {
   }
   }
 
 
   const withBundleAnalyzer = require('@next/bundle-analyzer')({
   const withBundleAnalyzer = require('@next/bundle-analyzer')({
-    enabled: phase === PHASE_PRODUCTION_BUILD
-      && (
-        process.env.ANALYZE === 'true'
-          || process.env.ANALYZE === '1'
-      ),
+    enabled:
+      phase === PHASE_PRODUCTION_BUILD &&
+      (process.env.ANALYZE === 'true' || process.env.ANALYZE === '1'),
   });
   });
 
 
   return withBundleAnalyzer(withSuperjson()(nextConfig));
   return withBundleAnalyzer(withSuperjson()(nextConfig));

+ 1 - 4
apps/app/nodemon.json

@@ -1,9 +1,6 @@
 {
 {
   "ext": "js,ts,json",
   "ext": "js,ts,json",
-  "watch": [
-    ".",
-    "../../packages/**/dist"
-  ],
+  "watch": [".", "../../packages/**/dist"],
   "ignore": [
   "ignore": [
     ".next",
     ".next",
     "public/static",
     "public/static",

+ 4 - 2
apps/app/package.json

@@ -28,6 +28,7 @@
     "launch-dev:ci": "cross-env NODE_ENV=development pnpm run dev:migrate && pnpm run ts-node src/server/app.ts --ci",
     "launch-dev:ci": "cross-env NODE_ENV=development pnpm run dev:migrate && pnpm run ts-node src/server/app.ts --ci",
     "lint:typecheck": "vue-tsc --noEmit",
     "lint:typecheck": "vue-tsc --noEmit",
     "lint:eslint": "eslint --quiet \"**/*.{js,mjs,jsx,ts,mts,tsx}\"",
     "lint:eslint": "eslint --quiet \"**/*.{js,mjs,jsx,ts,mts,tsx}\"",
+    "lint:biome": "biome check",
     "lint:styles": "stylelint \"src/**/*.scss\"",
     "lint:styles": "stylelint \"src/**/*.scss\"",
     "lint:openapi:apiv3": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv3.json",
     "lint:openapi:apiv3": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv3.json",
     "lint:openapi:apiv1": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv1.json",
     "lint:openapi:apiv1": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv1.json",
@@ -69,8 +70,9 @@
     "@azure/storage-blob": "^12.16.0",
     "@azure/storage-blob": "^12.16.0",
     "@browser-bunyan/console-formatted-stream": "^1.8.0",
     "@browser-bunyan/console-formatted-stream": "^1.8.0",
     "@cspell/dynamic-import": "^8.15.4",
     "@cspell/dynamic-import": "^8.15.4",
-    "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
-    "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
+    "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.4",
+    "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.18.2",
+    "@elastic/elasticsearch9": "npm:@elastic/elasticsearch@^9.0.3",
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@google-cloud/storage": "^5.8.5",
     "@growi/core": "workspace:^",
     "@growi/core": "workspace:^",

+ 14 - 14
apps/app/playwright.config.ts

@@ -10,18 +10,20 @@ const storageState = fs.existsSync(authFile) ? authFile : undefined;
 
 
 const supportedBrowsers = ['chromium', 'firefox', 'webkit'] as const;
 const supportedBrowsers = ['chromium', 'firefox', 'webkit'] as const;
 
 
-const projects: Array<Project> = supportedBrowsers.map(browser => ({
+const projects: Array<Project> = supportedBrowsers.map((browser) => ({
   name: browser,
   name: browser,
   use: { ...devices[`Desktop ${browser}`], storageState },
   use: { ...devices[`Desktop ${browser}`], storageState },
   testIgnore: /(10-installer|21-basic-features-for-guest)\/.*\.spec\.ts/,
   testIgnore: /(10-installer|21-basic-features-for-guest)\/.*\.spec\.ts/,
   dependencies: ['setup', 'auth'],
   dependencies: ['setup', 'auth'],
 }));
 }));
 
 
-const projectsForGuestMode: Array<Project> = supportedBrowsers.map(browser => ({
-  name: `${browser}/guest-mode`,
-  use: { ...devices[`Desktop ${browser}`] }, // Do not use storageState
-  testMatch: /21-basic-features-for-guest\/.*\.spec\.ts/,
-}));
+const projectsForGuestMode: Array<Project> = supportedBrowsers.map(
+  (browser) => ({
+    name: `${browser}/guest-mode`,
+    use: { ...devices[`Desktop ${browser}`] }, // Do not use storageState
+    testMatch: /21-basic-features-for-guest\/.*\.spec\.ts/,
+  }),
+);
 
 
 /**
 /**
  * Read environment variables from file.
  * Read environment variables from file.
@@ -48,12 +50,7 @@ export default defineConfig({
   /* Opt out of parallel tests on CI. */
   /* Opt out of parallel tests on CI. */
   workers: process.env.CI ? 1 : undefined,
   workers: process.env.CI ? 1 : undefined,
   /* Reporter to use. See https://playwright.dev/docs/test-reporters */
   /* Reporter to use. See https://playwright.dev/docs/test-reporters */
-  reporter: process.env.CI
-    ? [
-      ['github'],
-      ['blob'],
-    ]
-    : 'list',
+  reporter: process.env.CI ? [['github'], ['blob']] : 'list',
 
 
   webServer: {
   webServer: {
     command: 'pnpm run server',
     command: 'pnpm run server',
@@ -79,7 +76,11 @@ export default defineConfig({
   /* Configure projects for major browsers */
   /* Configure projects for major browsers */
   projects: [
   projects: [
     // Setup project
     // Setup project
-    { name: 'setup', testMatch: /.*\.setup\.ts/, testIgnore: /auth\.setup\.ts/ },
+    {
+      name: 'setup',
+      testMatch: /.*\.setup\.ts/,
+      testIgnore: /auth\.setup\.ts/,
+    },
     { name: 'auth', testMatch: /auth\.setup\.ts/ },
     { name: 'auth', testMatch: /auth\.setup\.ts/ },
 
 
     {
     {
@@ -113,5 +114,4 @@ export default defineConfig({
     //   use: { ...devices['Desktop Chrome'], channel: 'chrome' },
     //   use: { ...devices['Desktop Chrome'], channel: 'chrome' },
     // },
     // },
   ],
   ],
-
 });
 });

+ 24 - 0
apps/app/playwright/60-home/home.spec.ts

@@ -85,6 +85,30 @@ test('Access API setting', async({ page }) => {
   await expect(page.locator('.Toastify__toast')).toBeVisible();
   await expect(page.locator('.Toastify__toast')).toBeVisible();
 });
 });
 
 
+test('Access Access Token setting', async({ page }) => {
+  await page.goto('/me');
+
+  // Click ApiSettingTabButton
+  await expect(page.getByTestId('grw-user-settings')).toBeVisible();
+  await page.getByTestId('api-settings-tab-button').first().click();
+
+  // Expect a success toaster to be displayed when new Access Token is generated
+  await page.getByTestId('btn-accesstoken-toggleform').click();
+  await page.getByTestId('grw-accesstoken-textarea-description').fill('dummy');
+  await page.getByTestId('grw-accesstoken-checkbox-read:*').check();
+  await page.getByTestId('grw-accesstoken-create-button').click();
+  await expect(page.locator('.Toastify__toast')).toBeVisible();
+  await expect(page.getByTestId('grw-accesstoken-new-token-display')).toBeVisible();
+
+  // Expect a success toaster to be displayed when the Access Token is deleted
+  await page.getByTestId('grw-accesstoken-delete-button').click();
+  await page.getByTestId('grw-accesstoken-cancel-button-in-modal').click();
+  await page.getByTestId('grw-accesstoken-delete-button').click();
+  await page.getByTestId('grw-accesstoken-delete-button-in-modal').click();
+  await expect(page.locator('.Toastify__toast')).toBeVisible();
+
+});
+
 test('Access In-App Notification setting', async({ page }) => {
 test('Access In-App Notification setting', async({ page }) => {
   await page.goto('/me');
   await page.goto('/me');
 
 

+ 92 - 0
apps/app/public/static/locales/en_US/commons.json

@@ -123,5 +123,97 @@
     "transfer_key_limit": "Transfer keys are valid for 1 hour after issuance.",
     "transfer_key_limit": "Transfer keys are valid for 1 hour after issuance.",
     "once_transfer_key_used": "Once the transfer key is used for transfer, it cannot be used for any other transfer.",
     "once_transfer_key_used": "Once the transfer key is used for transfer, it cannot be used for any other transfer.",
     "transfer_to_growi_cloud": "For more details, please click <a href='{{documentationUrl}}en/admin-guide/management-cookbook/g2g-transfer.html'>here.</a>"
     "transfer_to_growi_cloud": "For more details, please click <a href='{{documentationUrl}}en/admin-guide/management-cookbook/g2g-transfer.html'>here.</a>"
+  },
+  "accesstoken_scopes_desc": {
+    "read": {
+      "all": "Grants permission to view all content.",
+      "admin": {
+        "all": "Grants permission to view admin features.",
+        "top": "Grants permission to view Wiki management top.",
+        "app": "Grants permission to view app settings.",
+        "security": "Grants permission to view security settings.",
+        "markdown": "Grants permission to view markdown settings.",
+        "customize": "Grants permission to view customization settings.",
+        "import_data": "Grants permission to view data import settings.",
+        "export_data": "Grants permission to view data archive settings.",
+        "data_transfer": "Grants permission to view data transfer settings.",
+        "external_notification": "Grants permission to view external notification settings.",
+        "slack_integration": "Grants permission to view Slack integration settings.",
+        "legacy_slack_integration": "Grants permission to view legacy Slack integration settings.",
+        "user_management": "Grants permission to view user management.",
+        "user_group_management": "Grants permission to view user group management.",
+        "audit_log": "Grants permission to view audit logs.",
+        "plugin": "Grants permission to view plugin settings.",
+        "ai_integration": "Grants permission to view AI integration settings.",
+        "full_text_search": "Grants permission to view full text search management."
+      },
+      "user_settings": {
+        "all": "Grants permission to view user settings.",
+        "info": "Grants permission to view user information.",
+        "external_account": "Grants permission to view external accounts.",
+        "password": "Grants permission to view password settings.",
+        "api": {
+          "all": "Grants permission to view API settings.",
+          "api_token": "Grants permission to view API token settings.",
+          "access_token": "Grants permission to view access token settings."
+        },
+        "in_app_notification": "Grants permission to view in-app notification settings.",
+        "other": "Grants permission to view other settings."
+      },
+      "features": {
+        "all": "Grants permission to view features.",
+        "ai_assistant": "Grants permission to view AI assistant features.",
+        "page": "Grants permission to view page features.",
+        "share_link": "Grants permission to view share link features.",
+        "bookmark": "Grants permission to view bookmark features.",
+        "attachment": "Grants permission to view attachment features.",
+        "page_bulk_export": "Grants permission to view page bulk export features."
+      }
+    },
+    "write": {
+      "all": "Grants permission to edit all content.",
+      "admin": {
+        "all": "Grants permission to edit admin features.",
+        "top": "Grants permission to edit Wiki management top.",
+        "app": "Grants permission to edit app settings.",
+        "security": "Grants permission to edit security settings.",
+        "markdown": "Grants permission to edit markdown settings.",
+        "customize": "Grants permission to edit customization settings.",
+        "import_data": "Grants permission to edit data import settings.",
+        "export_data": "Grants permission to edit data archive settings.",
+        "data_transfer": "Grants permission to edit data transfer settings.",
+        "external_notification": "Grants permission to edit external notification settings.",
+        "slack_integration": "Grants permission to edit Slack integration settings.",
+        "legacy_slack_integration": "Grants permission to edit legacy Slack integration settings.",
+        "user_management": "Grants permission to edit user management.",
+        "user_group_management": "Grants permission to edit user group management.",
+        "audit_log": "Grants permission to edit audit logs.",
+        "plugin": "Grants permission to edit plugin settings.",
+        "ai_integration": "Grants permission to edit AI integration settings.",
+        "full_text_search": "Grants permission to edit full text search management."
+      },
+      "user_settings": {
+        "all": "Grants permission to edit user settings.",
+        "info": "Grants permission to edit user information.",
+        "external_account": "Grants permission to edit external accounts.",
+        "password": "Grants permission to edit password settings.",
+        "api": {
+          "all": "Grants permission to edit API settings.",
+          "api_token": "Grants permission to edit API token settings.",
+          "access_token": "Grants permission to edit access token settings."
+        },
+        "in_app_notification": "Grants permission to edit in-app notification settings.",
+        "other": "Grants permission to edit other settings."
+      },
+      "features": {
+        "all": "Grants permission to edit features.",
+        "ai_assistant": "Grants permission to edit AI assistant features.",
+        "page": "Grants permission to edit page features.",
+        "share_link": "Grants permission to edit share link features.",
+        "bookmark": "Grants permission to edit bookmark features.",
+        "attachment": "Grants permission to edit attachment features.",
+        "page_bulk_export": "Grants permission to edit page bulk export features."
+      }
+    }
   }
   }
 }
 }

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

@@ -221,6 +221,9 @@
       "profile_image2": "Set up AWS or enable local uploads."
       "profile_image2": "Set up AWS or enable local uploads."
     }
     }
   },
   },
+  "API Token Settings": "API token settings",
+  "Current API Token": "Current API token",
+  "Update API Token": "Update API token",
   "page_me_apitoken": {
   "page_me_apitoken": {
     "api_token": "API Token",
     "api_token": "API Token",
     "notice": {
     "notice": {
@@ -230,6 +233,35 @@
     },
     },
     "form_help": {}
     "form_help": {}
   },
   },
+  "Access Token Settings": "Access token settings",
+  "page_me_access_token": {
+    "access_token": "Access token",
+    "expiredAt": "Expiration date",
+    "description": "Description",
+    "scope": "Scope",
+    "scope_read": "Read",
+    "action": "Action",
+    "create_token": "Create Token",
+    "no_tokens_found": "No access tokens found",
+    "new_token": {
+      "title": "New access token",
+      "copy_to_clipboard": "Copy to clipboard",
+      "message": "This token will only be displayed once. Please save it securely."
+    },
+    "modal": {
+      "message": "Are you sure you want to delete this access token?",
+      "alert": "This action cannot be undone.",
+      "delete_token": "Delete Token"
+    },
+    "form": {
+      "title": "Create New Access Token",
+      "expiredAt_desc": "Select when this access token should expire.",
+      "description_desc": "Provide a description to help you identify this token later",
+      "description_max_length": "Description must be less than {{length}} characters",
+      "scope_desc": "Select the scope of the access token."
+    },
+    "copy_to_clipboard": "Copy to clipboard"
+  },
   "Password": "Password",
   "Password": "Password",
   "Password Settings": "Password settings",
   "Password Settings": "Password settings",
   "personal_settings": {
   "personal_settings": {
@@ -260,9 +292,6 @@
   },
   },
   "API Settings": "API settings",
   "API Settings": "API settings",
   "Other Settings": "Other Settings",
   "Other Settings": "Other Settings",
-  "API Token Settings": "API token settings",
-  "Current API Token": "Current API token",
-  "Update API Token": "Update API token",
   "in_app_notification_settings": {
   "in_app_notification_settings": {
     "in_app_notification_settings": "In-App Notification Settings",
     "in_app_notification_settings": "In-App Notification Settings",
     "subscribe_settings": "Settings to automatically subscribe (Receive notifications) to pages",
     "subscribe_settings": "Settings to automatically subscribe (Receive notifications) to pages",

+ 92 - 0
apps/app/public/static/locales/fr_FR/commons.json

@@ -122,5 +122,97 @@
     "transfer_key_limit": "Les clés de transfert sont valides durant une heure.",
     "transfer_key_limit": "Les clés de transfert sont valides durant une heure.",
     "once_transfer_key_used": "Les clés de transfert sont à usage unique.",
     "once_transfer_key_used": "Les clés de transfert sont à usage unique.",
     "transfer_to_growi_cloud": "Pour plus de détails, veuillez cliquer <a href='{{documentationUrl}}en/admin-guide/management-cookbook/g2g-transfer.html'>ici.</a>"
     "transfer_to_growi_cloud": "Pour plus de détails, veuillez cliquer <a href='{{documentationUrl}}en/admin-guide/management-cookbook/g2g-transfer.html'>ici.</a>"
+  },
+  "accesstoken_scopes_desc": {
+    "read": {
+      "all": "Accorde la permission de voir tout le contenu.",
+      "admin": {
+        "all": "Accorde la permission de voir les fonctionnalités d'administration.",
+        "top": "Accorde la permission de voir la page principale de gestion du Wiki.",
+        "app": "Accorde la permission de voir les paramètres de l'application.",
+        "security": "Accorde la permission de voir les paramètres de sécurité.",
+        "markdown": "Accorde la permission de voir les paramètres markdown.",
+        "customize": "Accorde la permission de voir les paramètres de personnalisation.",
+        "import_data": "Accorde la permission de voir les paramètres d'importation de données.",
+        "export_data": "Accorde la permission de voir les paramètres d'archivage de données.",
+        "data_transfer": "Accorde la permission de voir les paramètres de transfert de données.",
+        "external_notification": "Accorde la permission de voir les paramètres de notification externe.",
+        "slack_integration": "Accorde la permission de voir les paramètres d'intégration Slack.",
+        "legacy_slack_integration": "Accorde la permission de voir les paramètres d'intégration Slack (ancien).",
+        "user_management": "Accorde la permission de voir la gestion des utilisateurs.",
+        "user_group_management": "Accorde la permission de voir la gestion des groupes d'utilisateurs.",
+        "audit_log": "Accorde la permission de voir les journaux d'audit.",
+        "plugin": "Accorde la permission de voir les paramètres des plugins.",
+        "ai_integration": "Accorde la permission de voir les paramètres d'intégration IA.",
+        "full_text_search": "Accorde la permission de voir la gestion de la recherche en texte intégral."
+      },
+      "user_settings": {
+        "all": "Accorde la permission de voir les paramètres utilisateur.",
+        "info": "Accorde la permission de voir les informations utilisateur.",
+        "external_account": "Accorde la permission de voir les comptes externes.",
+        "password": "Accorde la permission de voir les paramètres de mot de passe.",
+        "api": {
+          "all": "Accorde la permission de voir les paramètres API.",
+          "api_token": "Accorde la permission de voir les paramètres de jeton API.",
+          "access_token": "Accorde la permission de voir les paramètres de jeton d'accès."
+        },
+        "in_app_notification": "Accorde la permission de voir les paramètres de notification dans l'application.",
+        "other": "Accorde la permission de voir les autres paramètres."
+      },
+      "features": {
+        "all": "Accorde la permission de voir les fonctionnalités.",
+        "ai_assistant": "Accorde la permission de voir les fonctionnalités d'assistant IA.",
+        "page": "Accorde la permission de voir les fonctionnalités de page.",
+        "share_link": "Accorde la permission de voir les fonctionnalités de lien de partage.",
+        "bookmark": "Accorde la permission de voir les fonctionnalités de signet.",
+        "attachment": "Accorde la permission de voir les fonctionnalités de pièce jointe.",
+        "page_bulk_export": "Accorde la permission de voir les fonctionnalités d'exportation en masse de pages."
+      }
+    },
+    "write": {
+      "all": "Accorde la permission de modifier tout le contenu.",
+      "admin": {
+        "all": "Accorde la permission de modifier les fonctionnalités d'administration.",
+        "top": "Accorde la permission de modifier la page principale de gestion du Wiki.",
+        "app": "Accorde la permission de modifier les paramètres de l'application.",
+        "security": "Accorde la permission de modifier les paramètres de sécurité.",
+        "markdown": "Accorde la permission de modifier les paramètres markdown.",
+        "customize": "Accorde la permission de modifier les paramètres de personnalisation.",
+        "import_data": "Accorde la permission de modifier les paramètres d'importation de données.",
+        "export_data": "Accorde la permission de modifier les paramètres d'archivage de données.",
+        "data_transfer": "Accorde la permission de modifier les paramètres de transfert de données.",
+        "external_notification": "Accorde la permission de modifier les paramètres de notification externe.",
+        "slack_integration": "Accorde la permission de modifier les paramètres d'intégration Slack.",
+        "legacy_slack_integration": "Accorde la permission de modifier les paramètres d'intégration Slack (ancien).",
+        "user_management": "Accorde la permission de modifier la gestion des utilisateurs.",
+        "user_group_management": "Accorde la permission de modifier la gestion des groupes d'utilisateurs.",
+        "audit_log": "Accorde la permission de modifier les journaux d'audit.",
+        "plugin": "Accorde la permission de modifier les paramètres des plugins.",
+        "ai_integration": "Accorde la permission de modifier les paramètres d'intégration IA.",
+        "full_text_search": "Accorde la permission de modifier la gestion de la recherche en texte intégral."
+      },
+      "user_settings": {
+        "all": "Accorde la permission de modifier les paramètres utilisateur.",
+        "info": "Accorde la permission de modifier les informations utilisateur.",
+        "external_account": "Accorde la permission de modifier les comptes externes.",
+        "password": "Accorde la permission de modifier les paramètres de mot de passe.",
+        "api": {
+          "all": "Accorde la permission de modifier les paramètres API.",
+          "api_token": "Accorde la permission de modifier les paramètres de jeton API.",
+          "access_token": "Accorde la permission de modifier les paramètres de jeton d'accès."
+        },
+        "in_app_notification": "Accorde la permission de modifier les paramètres de notification dans l'application.",
+        "other": "Accorde la permission de modifier les autres paramètres."
+      },
+      "features": {
+        "all": "Accorde la permission de modifier les fonctionnalités.",
+        "ai_assistant": "Accorde la permission de modifier les fonctionnalités d'assistant IA.",
+        "page": "Accorde la permission de modifier les fonctionnalités de page.",
+        "share_link": "Accorde la permission de modifier les fonctionnalités de lien de partage.",
+        "bookmark": "Accorde la permission de modifier les fonctionnalités de signet.",
+        "attachment": "Accorde la permission de modifier les fonctionnalités de pièce jointe.",
+        "page_bulk_export": "Accorde la permission de modifier les fonctionnalités d'exportation en masse de pages."
+      }
+    }
   }
   }
 }
 }

+ 32 - 3
apps/app/public/static/locales/fr_FR/translation.json

@@ -222,6 +222,9 @@
       "profile_image2": "Configurer AWS ou activer le stockage local."
       "profile_image2": "Configurer AWS ou activer le stockage local."
     }
     }
   },
   },
+  "API Token Settings": "Jetons d'API",
+  "Current API Token": "Mon jeton d'API",
+  "Update API Token": "Regénérer",
   "page_me_apitoken": {
   "page_me_apitoken": {
     "api_token": "Jeton API",
     "api_token": "Jeton API",
     "notice": {
     "notice": {
@@ -231,6 +234,35 @@
     },
     },
     "form_help": {}
     "form_help": {}
   },
   },
+  "Access Token Settings": "Jeton d'accès",
+  "page_me_access_token": {
+    "access_token": "Jeton d'accès",
+    "expiredAt": "Date d'expiration",
+    "description": "Description",
+    "scope": "Portée",
+    "scope_read": "Lecture",
+    "action": "Action",
+    "create_token": "Créer un jeton",
+    "no_tokens_found": "Aucun jeton d'accès trouvé",
+    "new_token": {
+      "title": "Nouveau jeton d'accès",
+      "copy_to_clipboard": "Copier dans le presse-papiers",
+      "message": "Ce jeton ne sera affiché qu'une seule fois. Veuillez le sauvegarder en lieu sûr."
+    },
+    "modal": {
+      "message": "Êtes-vous sûr de vouloir supprimer ce jeton d'accès ?",
+      "alert": "Cette action ne peut pas être annulée.",
+      "delete_token": "Supprimer le jeton"
+    },
+    "form": {
+      "title": "Créer un nouveau jeton d'accès",
+      "expiredAt_desc": "Sélectionnez la date d'expiration de ce jeton d'accès.",
+      "description_desc": "Fournissez une description pour vous aider à identifier ce jeton ultérieurement.",
+      "description_max_length": "Veuillez saisir jusqu'à {{length}} caractères."
+      "scope_desc": "Sélectionnez la portée du jeton d'accès."
+    },
+    "copy_to_clipboard": "Copier dans le presse-papiers"
+  },
   "Password": "Mot de passe",
   "Password": "Mot de passe",
   "Password Settings": "Sécurité",
   "Password Settings": "Sécurité",
   "personal_settings": {
   "personal_settings": {
@@ -261,9 +293,6 @@
   },
   },
   "API Settings": "API GROWI",
   "API Settings": "API GROWI",
   "Other Settings": "Autres paramètres",
   "Other Settings": "Autres paramètres",
-  "API Token Settings": "Jetons d'API",
-  "Current API Token": "Mon jeton d'API",
-  "Update API Token": "Regénérer",
   "in_app_notification_settings": {
   "in_app_notification_settings": {
     "in_app_notification_settings": "Notifications",
     "in_app_notification_settings": "Notifications",
     "subscribe_settings": "Paramètres d'abonnement automatique aux notifications de pages",
     "subscribe_settings": "Paramètres d'abonnement automatique aux notifications de pages",

+ 93 - 0
apps/app/public/static/locales/ja_JP/commons.json

@@ -126,5 +126,98 @@
     "transfer_key_limit": "※ 移行キーの有効期限は発行から1時間となります。",
     "transfer_key_limit": "※ 移行キーの有効期限は発行から1時間となります。",
     "once_transfer_key_used": "※ 移行キーは一度移行に利用するとそれ以降はご利用いただけなくなります。",
     "once_transfer_key_used": "※ 移行キーは一度移行に利用するとそれ以降はご利用いただけなくなります。",
     "transfer_to_growi_cloud": "※ 詳しくは <a href='{{documentationUrl}}ja/admin-guide/management-cookbook/g2g-transfer.html'> GROWI お引越し機能</a>をご確認ください。"
     "transfer_to_growi_cloud": "※ 詳しくは <a href='{{documentationUrl}}ja/admin-guide/management-cookbook/g2g-transfer.html'> GROWI お引越し機能</a>をご確認ください。"
+  },
+
+  "accesstoken_scopes_desc": {
+    "read": {
+      "all": "全ての閲覧権限を付与できます。",
+      "admin": {
+        "all": "管理者機能の閲覧権限を付与できます。",
+        "top": "Wiki管理トップの閲覧権限を付与できます。",
+        "app": "アプリ設定の閲覧権限を付与できます。",
+        "security": "セキュリティ設定の閲覧権限を付与できます。",
+        "markdown": "マークダウン設定の閲覧権限を付与できます。",
+        "customize": "カスタマイズの閲覧権限を付与できます。",
+        "import_data": "データインポートの閲覧権限を付与できます。",
+        "export_data": "データアーカイブの閲覧権限を付与できます。",
+        "data_transfer": "データ移行の閲覧権限を付与できます。",
+        "external_notification": "外部ツールへの通知の閲覧権限を付与できます。",
+        "slack_integration": "Slack連携の閲覧権限を付与できます。",
+        "legacy_slack_integration": "Slack連携(レガシー)の閲覧権限を付与できます。",
+        "user_management": "ユーザー管理の閲覧権限を付与できます。",
+        "user_group_management": "ユーザーグループ管理の閲覧権限を付与できます。",
+        "audit_log": "監査ログの閲覧権限を付与できます。",
+        "plugin": "プラグインの閲覧権限を付与できます。",
+        "ai_integration": "AI連携設定の閲覧権限を付与できます。",
+        "full_text_search": "全文検索管理の閲覧権限を付与できます。"
+      },
+      "user_settings": {
+        "all": "ユーザー設定の閲覧権限を付与できます。",
+        "info": "ユーザー情報の閲覧権限を付与できます。",
+        "external_account": "外部アカウントの閲覧権限を付与できます。",
+        "password": "パスワード設定の閲覧権限を付与できます。",
+        "api": {
+          "all": "API 設定の閲覧権限を付与できます。",
+          "api_token": "API トークン設定の閲覧権限を付与できます。",
+          "access_token": "アクセストークン設定の閲覧権限を付与できます。"
+        },
+        "in_app_notification": "アプリ内通知設定の閲覧権限を付与できます。",
+        "other": "その他設定の閲覧権限を付与できます。"
+      },
+      "features": {
+        "all": "機能の閲覧権限を付与できます。",
+        "ai_assistant": "AIアシスタント機能の閲覧権限を付与できます。",
+        "page": "ページ機能の閲覧権限を付与できます。",
+        "share_link": "共有リンク機能の閲覧権限を付与できます。",
+        "bookmark": "ブックマーク機能の閲覧権限を付与できます。",
+        "attachment": "添付ファイル機能の閲覧権限を付与できます。",
+        "page_bulk_export": "ページの一括エクスポート機能の閲覧権限を付与できます。"
+      }
+    },
+    "write": {
+      "all": "全ての編集権限を付与できます。",
+      "admin": {
+        "all": "管理者機能の編集権限を付与できます。",
+        "top": "Wiki管理トップの編集権限を付与できます。",
+        "app": "アプリ設定の編集権限を付与できます。",
+        "security": "セキュリティ設定の編集権限を付与できます。",
+        "markdown": "マークダウン設定の編集権限を付与できます。",
+        "customize": "カスタマイズの編集権限を付与できます。",
+        "import_data": "データインポートの編集権限を付与できます。",
+        "export_data": "データアーカイブの編集権限を付与できます。",
+        "data_transfer": "データ移行の編集権限を付与できます。",
+        "external_notification": "外部ツールへの通知の編集権限を付与できます。",
+        "slack_integration": "Slack連携の編集権限を付与できます。",
+        "legacy_slack_integration": "Slack連携(レガシー)の編集権限を付与できます。",
+        "user_management": "ユーザー管理の編集権限を付与できます。",
+        "user_group_management": "ユーザーグループ管理の編集権限を付与できます。",
+        "audit_log": "監査ログの編集権限を付与できます。",
+        "plugin": "プラグインの編集権限を付与できます。",
+        "ai_integration": "AI連携設定の編集権限を付与できます。",
+        "full_text_search": "全文検索管理の編集権限を付与できます。"
+      },
+      "user_settings": {
+        "all": "ユーザー設定の編集権限を付与できます。",
+        "info": "ユーザー情報の編集権限を付与できます。",
+        "external_account": "外部アカウントの編集権限を付与できます。",
+        "password": "パスワード設定の編集権限を付与できます。",
+        "api": {
+          "all": "API 設定の編集権限を付与できます。",
+          "api_token": "API トークン設定の編集権限を付与できます。",
+          "access_token": "アクセストークン設定の編集権限を付与できます。"
+        },
+        "in_app_notification": "アプリ内通知設定の編集権限を付与できます。",
+        "other": "その他設定の編集権限を付与できます。"
+      },
+      "features": {
+        "all": "機能の編集権限を付与できます。",
+        "ai_assistant": "AIアシスタント機能の編集権限を付与できます。",
+        "page": "ページ機能の編集権限を付与できます。",
+        "share_link": "共有リンク機能の編集権限を付与できます。",
+        "bookmark": "ブックマーク機能の編集権限を付与できます。",
+        "attachment": "添付ファイル機能の編集権限を付与できます。",
+        "page_bulk_export": "ページの一括エクスポート機能の編集権限を付与できます。"
+      }
+    }
   }
   }
 }
 }

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

@@ -223,6 +223,9 @@
       "profile_image2": "アップロードできるようにするには、AWS またはローカルアップロードの設定をしてください。"
       "profile_image2": "アップロードできるようにするには、AWS またはローカルアップロードの設定をしてください。"
     }
     }
   },
   },
+  "API Token Settings": "API Token設定",
+  "Current API Token": "現在のAPI Token",
+  "Update API Token": "API Tokenを更新",
   "page_me_apitoken": {
   "page_me_apitoken": {
     "api_token": "API Token",
     "api_token": "API Token",
     "notice": {
     "notice": {
@@ -232,6 +235,35 @@
     },
     },
     "form_help": {}
     "form_help": {}
   },
   },
+  "Access Token Settings": "Access token 設定",
+  "page_me_access_token": {
+    "access_token": "Access token",
+    "expiredAt": "有効期限",
+    "description": "説明",
+    "scope": "スコープ",
+    "scope_read": "Read",
+    "action": "アクション",
+    "create_token": "トークンを作成",
+    "no_tokens_found":"アクセストークンが見つかりません",
+    "new_token": {
+      "title": "新しいアクセストークン",
+      "copy_to_clipboard": "クリップボードにコピーしました",
+      "message": "このアクセストークンは一度しか表示されません。安全に保存してください"
+    },
+    "modal": {
+      "message": "このアクセストークンを削除しますか?",
+      "alert": "この操作は取り消せません",
+      "delete_token": "トークンを削除"
+    },
+    "form": {
+      "title": "アクセストークンを作成",
+      "expiredAt_desc": "アクセストークンの有効期限を選択します。",
+      "description_desc": "このトークンを後で識別するための説明を入力します。",
+      "description_max_length": "{{length}}文字以内で入力してください。",
+      "scope_desc": "スコープによって、このトークンで行える操作を制限します。"
+    },
+    "copy_to_clipboard": "Copy to clipboard"
+  },
   "Password": "パスワード",
   "Password": "パスワード",
   "Password Settings": "パスワード設定",
   "Password Settings": "パスワード設定",
   "personal_settings": {
   "personal_settings": {
@@ -262,9 +294,6 @@
   },
   },
   "API Settings": "API設定",
   "API Settings": "API設定",
   "Other Settings": "その他の設定",
   "Other Settings": "その他の設定",
-  "API Token Settings": "API Token設定",
-  "Current API Token": "現在のAPI Token",
-  "Update API Token": "API Tokenを更新",
   "in_app_notification_settings": {
   "in_app_notification_settings": {
     "in_app_notification_settings": "アプリ内通知設定",
     "in_app_notification_settings": "アプリ内通知設定",
     "subscribe_settings": "自動でページをサブスクライブする(通知を受け取る)設定",
     "subscribe_settings": "自動でページをサブスクライブする(通知を受け取る)設定",

+ 92 - 0
apps/app/public/static/locales/zh_CN/commons.json

@@ -126,5 +126,97 @@
     "transfer_key_limit": "迁移密钥在签发后一小时内有效。",
     "transfer_key_limit": "迁移密钥在签发后一小时内有效。",
     "once_transfer_key_used": "一旦迁移密钥被用于迁移,它将不再可用于进一步的迁移。",
     "once_transfer_key_used": "一旦迁移密钥被用于迁移,它将不再可用于进一步的迁移。",
     "transfer_to_growi_cloud": "有关更多详情,请点击<a href='{{documentationUrl}}en/admin-guide/management-cookbook/g2g-transfer.html'>此处</a>。"
     "transfer_to_growi_cloud": "有关更多详情,请点击<a href='{{documentationUrl}}en/admin-guide/management-cookbook/g2g-transfer.html'>此处</a>。"
+  },
+  "accesstoken_scopes_desc": {
+    "read": {
+      "all": "授予查看所有内容的权限。",
+      "admin": {
+        "all": "授予查看管理功能的权限。",
+        "top": "授予查看Wiki管理顶部的权限。",
+        "app": "授予查看应用程序设置的权限。",
+        "security": "授予查看安全设置的权限。",
+        "markdown": "授予查看markdown设置的权限。",
+        "customize": "授予查看自定义设置的权限。",
+        "import_data": "授予查看数据导入设置的权限。",
+        "export_data": "授予查看数据归档设置的权限。",
+        "data_transfer": "授予查看数据迁移设置的权限。",
+        "external_notification": "授予查看外部通知设置的权限。",
+        "slack_integration": "授予查看Slack集成设置的权限。",
+        "legacy_slack_integration": "授予查看旧版Slack集成设置的权限。",
+        "user_management": "授予查看用户管理的权限。",
+        "user_group_management": "授予查看用户组管理的权限。",
+        "audit_log": "授予查看审计日志的权限。",
+        "plugin": "授予查看插件设置的权限。",
+        "ai_integration": "授予查看AI集成设置的权限。",
+        "full_text_search": "授予查看全文搜索管理的权限。"
+      },
+      "user_settings": {
+        "all": "授予查看用户设置的权限。",
+        "info": "授予查看用户信息的权限。",
+        "external_account": "授予查看外部账户的权限。",
+        "password": "授予查看密码设置的权限。",
+        "api": {
+          "all": "授予查看API设置的权限。",
+          "api_token": "授予查看API令牌设置的权限。",
+          "access_token": "授予查看访问令牌设置的权限。"
+        },
+        "in_app_notification": "授予查看应用内通知设置的权限。",
+        "other": "授予查看其他设置的权限。"
+      },
+      "features": {
+        "all": "授予查看功能的权限。",
+        "ai_assistant": "授予查看AI助手功能的权限。",
+        "page": "授予查看页面功能的权限。",
+        "share_link": "授予查看共享链接功能的权限。",
+        "bookmark": "授予查看书签功能的权限。",
+        "attachment": "授予查看附件功能的权限。",
+        "page_bulk_export": "授予查看页面批量导出功能的权限。"
+      }
+    },
+    "write": {
+      "all": "授予编辑所有内容的权限。",
+      "admin": {
+        "all": "授予编辑管理功能的权限。",
+        "top": "授予编辑Wiki管理顶部的权限。",
+        "app": "授予编辑应用程序设置的权限。",
+        "security": "授予编辑安全设置的权限。",
+        "markdown": "授予编辑markdown设置的权限。",
+        "customize": "授予编辑自定义设置的权限。",
+        "import_data": "授予编辑数据导入设置的权限。",
+        "export_data": "授予编辑数据归档设置的权限。",
+        "data_transfer": "授予编辑数据迁移设置的权限。",
+        "external_notification": "授予编辑外部通知设置的权限。",
+        "slack_integration": "授予编辑Slack集成设置的权限。",
+        "legacy_slack_integration": "授予编辑旧版Slack集成设置的权限。",
+        "user_management": "授予编辑用户管理的权限。",
+        "user_group_management": "授予编辑用户组管理的权限。",
+        "audit_log": "授予编辑审计日志的权限。",
+        "plugin": "授予编辑插件设置的权限。",
+        "ai_integration": "授予编辑AI集成设置的权限。",
+        "full_text_search": "授予编辑全文搜索管理的权限。"
+      },
+      "user_settings": {
+        "all": "授予编辑用户设置的权限。",
+        "info": "授予编辑用户信息的权限。",
+        "external_account": "授予编辑外部账户的权限。",
+        "password": "授予编辑密码设置的权限。",
+        "api": {
+          "all": "授予编辑API设置的权限。",
+          "api_token": "授予编辑API令牌设置的权限。",
+          "access_token": "授予编辑访问令牌设置的权限。"
+        },
+        "in_app_notification": "授予编辑应用内通知设置的权限。",
+        "other": "授予编辑其他设置的权限。"
+      },
+      "features": {
+        "all": "授予编辑功能的权限。",
+        "ai_assistant": "授予编辑AI助手功能的权限。",
+        "page": "授予编辑页面功能的权限。",
+        "share_link": "授予编辑共享链接功能的权限。",
+        "bookmark": "授予编辑书签功能的权限。",
+        "attachment": "授予编辑附件功能的权限。",
+        "page_bulk_export": "授予编辑页面批量导出功能的权限。"
+      }
+    }
   }
   }
 }
 }

+ 34 - 4
apps/app/public/static/locales/zh_CN/translation.json

@@ -230,13 +230,46 @@
       "profile_image2": "设置AWS或启用本地上传。"
       "profile_image2": "设置AWS或启用本地上传。"
     }
     }
   },
   },
+  "API Token Settings": "API token 设置",
+  "Current API Token": "当前 API token",
+  "Update API Token": "更新 API token",
   "page_me_apitoken": {
   "page_me_apitoken": {
     "api_token": "API Token",
     "api_token": "API Token",
     "notice": {
     "notice": {
       "apitoken_issued": "API token 未发布。",
       "apitoken_issued": "API token 未发布。",
       "update_token1": "您可以更新以生成新的API令牌。",
       "update_token1": "您可以更新以生成新的API令牌。",
       "update_token2": "您需要更新任何现有进程中的API令牌。"
       "update_token2": "您需要更新任何现有进程中的API令牌。"
-    }
+    },
+    "form_help": {}
+  },
+  "Access Token Settings": "Access token 设置",
+  "page_me_access_token": {
+    "access_token": "访问令牌",
+    "expiredAt": "过期日期",
+    "description": "描述",
+    "scope": "范围",
+    "scope_read": "读取",
+    "action": "操作",
+    "create_token": "创建令牌",
+    "no_tokens_found": "未找到访问令牌",
+    "new_token": {
+      "title": "新访问令牌",
+      "copy_to_clipboard": "复制到剪贴板",
+      "message": "此令牌仅显示一次。请安全保存。"
+    },
+    "modal": {
+      "message": "确定要删除此访问令牌吗?",
+      "alert": "此操作无法撤消。",
+      "delete_token": "删除令牌"
+    },
+    "form": {
+      "title": "创建新访问令牌",
+      "expiredAt_desc": "选择此访问令牌的过期时间。",
+      "description_desc": "提供描述以帮助您稍后识别此令牌。",
+      "description_max_length": "请输入最多 {{length}} 个字符",
+      "scope_desc": "选择访问令牌的范围。"
+    },
+    "copy_to_clipboard": "复制到剪贴板"
   },
   },
   "Password": "密码",
   "Password": "密码",
   "Password Settings": "密码设置",
   "Password Settings": "密码设置",
@@ -252,9 +285,6 @@
   },
   },
   "API Settings": "API设置",
   "API Settings": "API设置",
   "Other Settings": "其他设置",
   "Other Settings": "其他设置",
-  "API Token Settings": "API token 设置",
-  "Current API Token": "当前 API token",
-  "Update API Token": "更新 API token",
   "in_app_notification_settings": {
   "in_app_notification_settings": {
     "in_app_notification_settings": "在应用程序通知设置",
     "in_app_notification_settings": "在应用程序通知设置",
     "subscribe_settings": "自动订阅(接收通知)页面的设置",
     "subscribe_settings": "自动订阅(接收通知)页面的设置",

+ 0 - 119
apps/app/resource/search/mappings-es7.json

@@ -1,119 +0,0 @@
-{
-  "settings": {
-    "analysis": {
-      "filter": {
-        "english_stop": {
-          "type":       "stop",
-          "stopwords":  "_english_"
-        }
-      },
-      "tokenizer": {
-        "edge_ngram_tokenizer": {
-          "type": "edge_ngram",
-          "min_gram": 2,
-          "max_gram": 20,
-          "token_chars": ["letter", "digit"]
-        }
-      },
-      "analyzer": {
-        "japanese": {
-          "tokenizer": "kuromoji_tokenizer",
-          "char_filter" : ["icu_normalizer"]
-        },
-        "english_edge_ngram": {
-          "tokenizer": "edge_ngram_tokenizer",
-          "filter": [
-            "lowercase",
-            "english_stop"
-          ]
-        }
-      }
-    }
-  },
-  "mappings": {
-    "properties" : {
-      "path": {
-        "type": "text",
-        "fields": {
-          "raw": {
-            "type": "text",
-            "analyzer": "keyword"
-          },
-          "ja": {
-            "type": "text",
-            "analyzer": "japanese"
-          },
-          "en": {
-            "type": "text",
-            "analyzer": "english_edge_ngram",
-            "search_analyzer": "standard"
-          }
-        }
-      },
-      "body": {
-        "type": "text",
-        "fields": {
-          "ja": {
-            "type": "text",
-            "analyzer": "japanese"
-          },
-          "en": {
-            "type": "text",
-            "analyzer": "english_edge_ngram",
-            "search_analyzer": "standard"
-          }
-        }
-      },
-      "body_embedded": {
-        "type": "dense_vector",
-        "dims": 768
-      },
-      "comments": {
-        "type": "text",
-        "fields": {
-          "ja": {
-            "type": "text",
-            "analyzer": "japanese"
-          },
-          "en": {
-            "type": "text",
-            "analyzer": "english_edge_ngram",
-            "search_analyzer": "standard"
-          }
-        }
-      },
-      "username": {
-        "type": "keyword"
-      },
-      "comment_count": {
-        "type": "integer"
-      },
-      "bookmark_count": {
-        "type": "integer"
-      },
-      "like_count": {
-        "type": "integer"
-      },
-      "grant": {
-        "type": "integer"
-      },
-      "granted_users": {
-        "type": "keyword"
-      },
-      "granted_groups": {
-        "type": "keyword"
-      },
-      "created_at": {
-        "type": "date",
-        "format": "dateOptionalTime"
-      },
-      "updated_at": {
-        "type": "date",
-        "format": "dateOptionalTime"
-      },
-      "tag_names": {
-        "type": "keyword"
-      }
-    }
-  }
-}

+ 129 - 0
apps/app/resource/search/mappings-es7.ts

@@ -0,0 +1,129 @@
+// TODO: https://redmine.weseek.co.jp/issues/168446
+import type { estypes } from '@elastic/elasticsearch7';
+
+type Mappings = {
+  settings: NonNullable<estypes.IndicesCreateRequest['body']>['settings'];
+  mappings: NonNullable<estypes.IndicesCreateRequest['body']>['mappings'];
+}
+
+export const mappings: Mappings = {
+  settings: {
+    analysis: {
+      filter: {
+        english_stop: {
+          type:       'stop',
+          stopwords:  '_english_',
+        },
+      },
+      tokenizer: {
+        edge_ngram_tokenizer: {
+          type: 'edge_ngram',
+          min_gram: 2,
+          max_gram: 20,
+          token_chars: ['letter', 'digit'],
+        },
+      },
+      analyzer: {
+        japanese: {
+          type: 'custom',
+          tokenizer: 'kuromoji_tokenizer',
+          char_filter: ['icu_normalizer'],
+        },
+        english_edge_ngram: {
+          type: 'custom',
+          tokenizer: 'edge_ngram_tokenizer',
+          filter: [
+            'lowercase',
+            'english_stop',
+          ],
+        },
+      },
+    },
+  },
+  mappings: {
+    properties: {
+      path: {
+        type: 'text',
+        fields: {
+          raw: {
+            type: 'text',
+            analyzer: 'keyword',
+          },
+          ja: {
+            type: 'text',
+            analyzer: 'japanese',
+          },
+          en: {
+            type: 'text',
+            analyzer: 'english_edge_ngram',
+            search_analyzer: 'standard',
+          },
+        },
+      },
+      body: {
+        type: 'text',
+        fields: {
+          ja: {
+            type: 'text',
+            analyzer: 'japanese',
+          },
+          en: {
+            type: 'text',
+            analyzer: 'english_edge_ngram',
+            search_analyzer: 'standard',
+          },
+        },
+      },
+      body_embedded: {
+        type: 'dense_vector',
+        dims: 768,
+      },
+      comments: {
+        type: 'text',
+        fields: {
+          ja: {
+            type: 'text',
+            analyzer: 'japanese',
+          },
+          en: {
+            type: 'text',
+            analyzer: 'english_edge_ngram',
+            search_analyzer: 'standard',
+          },
+        },
+      },
+      username: {
+        type: 'keyword',
+      },
+      comment_count: {
+        type: 'integer',
+      },
+      bookmark_count: {
+        type: 'integer',
+      },
+      like_count: {
+        type: 'integer',
+      },
+      grant: {
+        type: 'integer',
+      },
+      granted_users: {
+        type: 'keyword',
+      },
+      granted_groups: {
+        type: 'keyword',
+      },
+      created_at: {
+        type: 'date',
+        format: 'dateOptionalTime',
+      },
+      updated_at: {
+        type: 'date',
+        format: 'dateOptionalTime',
+      },
+      tag_names: {
+        type: 'keyword',
+      },
+    },
+  },
+};

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

@@ -1,118 +0,0 @@
-{
-  "settings": {
-    "analysis": {
-      "filter": {
-        "english_stop": {
-          "type":       "stop",
-          "stopwords":  "_english_"
-        }
-      },
-      "tokenizer": {
-        "edge_ngram_tokenizer": {
-          "type": "edge_ngram",
-          "min_gram": 2,
-          "max_gram": 20,
-          "token_chars": ["letter", "digit"]
-        }
-      },
-      "analyzer": {
-        "japanese": {
-          "tokenizer": "edge_ngram_tokenizer",
-          "filter": [
-            "lowercase",
-            "english_stop"
-          ]
-        },
-        "english_edge_ngram": {
-          "tokenizer": "edge_ngram_tokenizer",
-          "filter": [
-            "lowercase",
-            "english_stop"
-          ]
-        }
-      }
-    }
-  },
-  "mappings": {
-    "properties" : {
-      "path": {
-        "type": "text",
-        "fields": {
-          "raw": {
-            "type": "text",
-            "analyzer": "keyword"
-          },
-          "ja": {
-            "type": "text",
-            "analyzer": "japanese"
-          },
-          "en": {
-            "type": "text",
-            "analyzer": "english_edge_ngram",
-            "search_analyzer": "standard"
-          }
-        }
-      },
-      "body": {
-        "type": "text",
-        "fields": {
-          "ja": {
-            "type": "text",
-            "analyzer": "japanese"
-          },
-          "en": {
-            "type": "text",
-            "analyzer": "english_edge_ngram",
-            "search_analyzer": "standard"
-          }
-        }
-      },
-      "comments": {
-        "type": "text",
-        "fields": {
-          "ja": {
-            "type": "text",
-            "analyzer": "japanese"
-          },
-          "en": {
-            "type": "text",
-            "analyzer": "english_edge_ngram",
-            "search_analyzer": "standard"
-          }
-        }
-      },
-      "username": {
-        "type": "keyword"
-      },
-      "comment_count": {
-        "type": "integer"
-      },
-      "bookmark_count": {
-        "type": "integer"
-      },
-      "like_count": {
-        "type": "integer"
-      },
-      "grant": {
-        "type": "integer"
-      },
-      "granted_users": {
-        "type": "keyword"
-      },
-      "granted_groups": {
-        "type": "keyword"
-      },
-      "created_at": {
-        "type": "date",
-        "format": "date_optional_time"
-      },
-      "updated_at": {
-        "type": "date",
-        "format": "date_optional_time"
-      },
-      "tag_names": {
-        "type": "keyword"
-      }
-    }
-  }
-}

+ 0 - 119
apps/app/resource/search/mappings-es8.json

@@ -1,119 +0,0 @@
-{
-  "settings": {
-    "analysis": {
-      "filter": {
-        "english_stop": {
-          "type":       "stop",
-          "stopwords":  "_english_"
-        }
-      },
-      "tokenizer": {
-        "edge_ngram_tokenizer": {
-          "type": "edge_ngram",
-          "min_gram": 2,
-          "max_gram": 20,
-          "token_chars": ["letter", "digit"]
-        }
-      },
-      "analyzer": {
-        "japanese": {
-          "tokenizer": "kuromoji_tokenizer",
-          "char_filter" : ["icu_normalizer"]
-        },
-        "english_edge_ngram": {
-          "tokenizer": "edge_ngram_tokenizer",
-          "filter": [
-            "lowercase",
-            "english_stop"
-          ]
-        }
-      }
-    }
-  },
-  "mappings": {
-    "properties" : {
-      "path": {
-        "type": "text",
-        "fields": {
-          "raw": {
-            "type": "text",
-            "analyzer": "keyword"
-          },
-          "ja": {
-            "type": "text",
-            "analyzer": "japanese"
-          },
-          "en": {
-            "type": "text",
-            "analyzer": "english_edge_ngram",
-            "search_analyzer": "standard"
-          }
-        }
-      },
-      "body": {
-        "type": "text",
-        "fields": {
-          "ja": {
-            "type": "text",
-            "analyzer": "japanese"
-          },
-          "en": {
-            "type": "text",
-            "analyzer": "english_edge_ngram",
-            "search_analyzer": "standard"
-          }
-        }
-      },
-      "body_embedded": {
-        "type": "dense_vector",
-        "dims": 768
-      },
-      "comments": {
-        "type": "text",
-        "fields": {
-          "ja": {
-            "type": "text",
-            "analyzer": "japanese"
-          },
-          "en": {
-            "type": "text",
-            "analyzer": "english_edge_ngram",
-            "search_analyzer": "standard"
-          }
-        }
-      },
-      "username": {
-        "type": "keyword"
-      },
-      "comment_count": {
-        "type": "integer"
-      },
-      "bookmark_count": {
-        "type": "integer"
-      },
-      "like_count": {
-        "type": "integer"
-      },
-      "grant": {
-        "type": "integer"
-      },
-      "granted_users": {
-        "type": "keyword"
-      },
-      "granted_groups": {
-        "type": "keyword"
-      },
-      "created_at": {
-        "type": "date",
-        "format": "date_optional_time"
-      },
-      "updated_at": {
-        "type": "date",
-        "format": "date_optional_time"
-      },
-      "tag_names": {
-        "type": "keyword"
-      }
-    }
-  }
-}

+ 128 - 0
apps/app/resource/search/mappings-es8.ts

@@ -0,0 +1,128 @@
+import type { estypes } from '@elastic/elasticsearch8';
+
+type Mappings = {
+  settings: estypes.IndicesCreateRequest['settings'];
+  mappings: estypes.IndicesCreateRequest['mappings'];
+}
+
+export const mappings: Mappings = {
+  settings: {
+    analysis: {
+      filter: {
+        english_stop: {
+          type:       'stop',
+          stopwords:  '_english_',
+        },
+      },
+      tokenizer: {
+        edge_ngram_tokenizer: {
+          type: 'edge_ngram',
+          min_gram: 2,
+          max_gram: 20,
+          token_chars: ['letter', 'digit'],
+        },
+      },
+      analyzer: {
+        japanese: {
+          type: 'custom',
+          tokenizer: 'kuromoji_tokenizer',
+          char_filter: ['icu_normalizer'],
+        },
+        english_edge_ngram: {
+          type: 'custom',
+          tokenizer: 'edge_ngram_tokenizer',
+          filter: [
+            'lowercase',
+            'english_stop',
+          ],
+        },
+      },
+    },
+  },
+  mappings: {
+    properties: {
+      path: {
+        type: 'text',
+        fields: {
+          raw: {
+            type: 'text',
+            analyzer: 'keyword',
+          },
+          ja: {
+            type: 'text',
+            analyzer: 'japanese',
+          },
+          en: {
+            type: 'text',
+            analyzer: 'english_edge_ngram',
+            search_analyzer: 'standard',
+          },
+        },
+      },
+      body: {
+        type: 'text',
+        fields: {
+          ja: {
+            type: 'text',
+            analyzer: 'japanese',
+          },
+          en: {
+            type: 'text',
+            analyzer: 'english_edge_ngram',
+            search_analyzer: 'standard',
+          },
+        },
+      },
+      body_embedded: {
+        type: 'dense_vector',
+        dims: 768,
+      },
+      comments: {
+        type: 'text',
+        fields: {
+          ja: {
+            type: 'text',
+            analyzer: 'japanese',
+          },
+          en: {
+            type: 'text',
+            analyzer: 'english_edge_ngram',
+            search_analyzer: 'standard',
+          },
+        },
+      },
+      username: {
+        type: 'keyword',
+      },
+      comment_count: {
+        type: 'integer',
+      },
+      bookmark_count: {
+        type: 'integer',
+      },
+      like_count: {
+        type: 'integer',
+      },
+      grant: {
+        type: 'integer',
+      },
+      granted_users: {
+        type: 'keyword',
+      },
+      granted_groups: {
+        type: 'keyword',
+      },
+      created_at: {
+        type: 'date',
+        format: 'date_optional_time',
+      },
+      updated_at: {
+        type: 'date',
+        format: 'date_optional_time',
+      },
+      tag_names: {
+        type: 'keyword',
+      },
+    },
+  },
+};

+ 127 - 0
apps/app/resource/search/mappings-es9-for-ci.ts

@@ -0,0 +1,127 @@
+import type { estypes } from '@elastic/elasticsearch9';
+
+type Mappings = {
+  settings: estypes.IndicesCreateRequest['settings'];
+  mappings: estypes.IndicesCreateRequest['mappings'];
+}
+
+export const mappings: Mappings = {
+  settings: {
+    analysis: {
+      filter: {
+        english_stop: {
+          type:       'stop',
+          stopwords:  '_english_',
+        },
+      },
+      tokenizer: {
+        edge_ngram_tokenizer: {
+          type: 'edge_ngram',
+          min_gram: 2,
+          max_gram: 20,
+          token_chars: ['letter', 'digit'],
+        },
+      },
+      analyzer: {
+        japanese: {
+          type: 'custom',
+          tokenizer: 'edge_ngram_tokenizer',
+          filter: [
+            'lowercase',
+            'english_stop',
+          ],
+        },
+        english_edge_ngram: {
+          type: 'custom',
+          tokenizer: 'edge_ngram_tokenizer',
+          filter: [
+            'lowercase',
+            'english_stop',
+          ],
+        },
+      },
+    },
+  },
+  mappings: {
+    properties: {
+      path: {
+        type: 'text',
+        fields: {
+          raw: {
+            type: 'text',
+            analyzer: 'keyword',
+          },
+          ja: {
+            type: 'text',
+            analyzer: 'japanese',
+          },
+          en: {
+            type: 'text',
+            analyzer: 'english_edge_ngram',
+            search_analyzer: 'standard',
+          },
+        },
+      },
+      body: {
+        type: 'text',
+        fields: {
+          ja: {
+            type: 'text',
+            analyzer: 'japanese',
+          },
+          en: {
+            type: 'text',
+            analyzer: 'english_edge_ngram',
+            search_analyzer: 'standard',
+          },
+        },
+      },
+      comments: {
+        type: 'text',
+        fields: {
+          ja: {
+            type: 'text',
+            analyzer: 'japanese',
+          },
+          en: {
+            type: 'text',
+            analyzer: 'english_edge_ngram',
+            search_analyzer: 'standard',
+          },
+        },
+      },
+      username: {
+        type: 'keyword',
+      },
+      comment_count: {
+        type: 'integer',
+      },
+      bookmark_count: {
+        type: 'integer',
+      },
+      like_count: {
+        type: 'integer',
+      },
+      grant: {
+        type: 'integer',
+      },
+      granted_users: {
+        type: 'keyword',
+      },
+      granted_groups: {
+        type: 'keyword',
+      },
+      created_at: {
+        type: 'date',
+        format: 'date_optional_time',
+      },
+      updated_at: {
+        type: 'date',
+        format: 'date_optional_time',
+      },
+      tag_names: {
+        type: 'keyword',
+      },
+    },
+  },
+};

+ 128 - 0
apps/app/resource/search/mappings-es9.ts

@@ -0,0 +1,128 @@
+import type { estypes } from '@elastic/elasticsearch9';
+
+type Mappings = {
+  settings: estypes.IndicesCreateRequest['settings'];
+  mappings: estypes.IndicesCreateRequest['mappings'];
+}
+
+export const mappings: Mappings = {
+  settings: {
+    analysis: {
+      filter: {
+        english_stop: {
+          type:       'stop',
+          stopwords:  '_english_',
+        },
+      },
+      tokenizer: {
+        edge_ngram_tokenizer: {
+          type: 'edge_ngram',
+          min_gram: 2,
+          max_gram: 20,
+          token_chars: ['letter', 'digit'],
+        },
+      },
+      analyzer: {
+        japanese: {
+          type: 'custom',
+          tokenizer: 'kuromoji_tokenizer',
+          char_filter: ['icu_normalizer'],
+        },
+        english_edge_ngram: {
+          type: 'custom',
+          tokenizer: 'edge_ngram_tokenizer',
+          filter: [
+            'lowercase',
+            'english_stop',
+          ],
+        },
+      },
+    },
+  },
+  mappings: {
+    properties: {
+      path: {
+        type: 'text',
+        fields: {
+          raw: {
+            type: 'text',
+            analyzer: 'keyword',
+          },
+          ja: {
+            type: 'text',
+            analyzer: 'japanese',
+          },
+          en: {
+            type: 'text',
+            analyzer: 'english_edge_ngram',
+            search_analyzer: 'standard',
+          },
+        },
+      },
+      body: {
+        type: 'text',
+        fields: {
+          ja: {
+            type: 'text',
+            analyzer: 'japanese',
+          },
+          en: {
+            type: 'text',
+            analyzer: 'english_edge_ngram',
+            search_analyzer: 'standard',
+          },
+        },
+      },
+      body_embedded: {
+        type: 'dense_vector',
+        dims: 768,
+      },
+      comments: {
+        type: 'text',
+        fields: {
+          ja: {
+            type: 'text',
+            analyzer: 'japanese',
+          },
+          en: {
+            type: 'text',
+            analyzer: 'english_edge_ngram',
+            search_analyzer: 'standard',
+          },
+        },
+      },
+      username: {
+        type: 'keyword',
+      },
+      comment_count: {
+        type: 'integer',
+      },
+      bookmark_count: {
+        type: 'integer',
+      },
+      like_count: {
+        type: 'integer',
+      },
+      grant: {
+        type: 'integer',
+      },
+      granted_users: {
+        type: 'keyword',
+      },
+      granted_groups: {
+        type: 'keyword',
+      },
+      created_at: {
+        type: 'date',
+        format: 'date_optional_time',
+      },
+      updated_at: {
+        type: 'date',
+        format: 'date_optional_time',
+      },
+      tag_names: {
+        type: 'keyword',
+      },
+    },
+  },
+};

+ 143 - 0
apps/app/src/client/components/Me/AccessTokenForm.tsx

@@ -0,0 +1,143 @@
+import React from 'react';
+
+import type { Scope } from '@growi/core/dist/interfaces';
+import { useTranslation } from 'next-i18next';
+import { useForm } from 'react-hook-form';
+
+import type { IAccessTokenInfo } from '~/interfaces/access-token';
+
+import { AccessTokenScopeSelect } from './AccessTokenScopeSelect';
+
+const MAX_DESCRIPTION_LENGTH = 200;
+
+type AccessTokenFormProps = {
+  submitHandler: (info: IAccessTokenInfo) => Promise<void>;
+}
+
+type FormInputs = {
+  expiredAt: string;
+  description: string;
+  scopes: Scope[];
+}
+
+export const AccessTokenForm = React.memo((props: AccessTokenFormProps): JSX.Element => {
+  const { submitHandler } = props;
+  const { t } = useTranslation();
+
+  const defaultExpiredAt = new Date();
+  defaultExpiredAt.setMonth(defaultExpiredAt.getMonth() + 1);
+  const defaultExpiredAtStr = defaultExpiredAt.toISOString().split('T')[0];
+  const todayStr = new Date().toISOString().split('T')[0];
+
+  const {
+    register,
+    handleSubmit,
+    formState: { errors, isValid },
+    watch,
+  } = useForm<FormInputs>({
+    defaultValues: {
+      expiredAt: defaultExpiredAtStr,
+      description: '',
+      scopes: [],
+    },
+  });
+
+  const onSubmit = (data: FormInputs) => {
+    const expiredAtDate = new Date(data.expiredAt);
+    expiredAtDate.setHours(23, 59, 59, 999);
+    const scopes: Scope[] = data.scopes ? data.scopes : [];
+
+    submitHandler({
+      expiredAt: expiredAtDate,
+      description: data.description,
+      scopes,
+    });
+  };
+
+  return (
+    <div className="card mt-3 mb-4">
+      <div className="card-header">{t('page_me_access_token.form.title')}</div>
+      <div className="card-body">
+        <form onSubmit={handleSubmit(onSubmit)}>
+          <div className="mb-3">
+            <label htmlFor="expiredAt" className="form-label">{t('page_me_access_token.expiredAt')}</label>
+            <div className="row">
+              <div className="col-16 col-sm-4 col-md-4 col-lg-3">
+                <div className="input-group">
+                  <input
+                    type="date"
+                    className={`form-control ${errors.expiredAt ? 'is-invalid' : ''}`}
+                    data-testid="grw-accesstoken-input-expiredAt"
+                    min={todayStr}
+                    {...register('expiredAt', {
+                      required: t('input_validation.message.required', { param: t('page_me_access_token.expiredAt') }),
+                    })}
+                  />
+                </div>
+                {errors.expiredAt && (
+                  <div className="invalid-feedback d-block">
+                    {errors.expiredAt.message}
+                  </div>
+                )}
+              </div>
+            </div>
+            <div className="form-text">{t('page_me_access_token.form.expiredAt_desc')}</div>
+          </div>
+
+          <div className="mb-3">
+            <label htmlFor="description" className="form-label">{t('page_me_access_token.description')}</label>
+            <textarea
+              className={`form-control ${errors.description ? 'is-invalid' : ''}`}
+              rows={3}
+              data-testid="grw-accesstoken-textarea-description"
+              {...register('description', {
+                required: t('input_validation.message.required', { param: t('page_me_access_token.description') }),
+                maxLength: {
+                  value: MAX_DESCRIPTION_LENGTH,
+                  message: t('page_me_access_token.form.description_max_length', { length: MAX_DESCRIPTION_LENGTH }),
+                },
+              })}
+            />
+            {errors.description && (
+              <div className="invalid-feedback">
+                {errors.description.message}
+              </div>
+            )}
+            <div className="form-text">{t('page_me_access_token.form.description_desc')}</div>
+          </div>
+
+          <div className="mb-3">
+            <label htmlFor="scopes" className="form-label">
+              {t('page_me_access_token.scope')}
+            </label>
+            <AccessTokenScopeSelect
+              selectedScopes={watch('scopes')}
+              register={register('scopes', {
+                required: t('input_validation.message.required', { param: t('page_me_access_token.scope') }),
+              })}
+            />
+            {errors.scopes && (
+              <div className="invalid-feedback">
+                {errors.scopes.message}
+              </div>
+            )}
+
+            <div className="form-text mb-2">
+              {t('page_me_access_token.form.scope_desc')}
+            </div>
+          </div>
+
+          <button
+            type="submit"
+            className="btn btn-primary"
+            data-testid="grw-accesstoken-create-button"
+            disabled={!isValid}
+          >
+            {t('page_me_access_token.create_token')}
+          </button>
+        </form>
+      </div>
+    </div>
+  );
+});
+AccessTokenForm.displayName = 'AccessTokenForm';

+ 105 - 0
apps/app/src/client/components/Me/AccessTokenList.tsx

@@ -0,0 +1,105 @@
+import React, { useState } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter, Button,
+} from 'reactstrap';
+
+import type { IResGetAccessToken } from '~/interfaces/access-token';
+
+type AccessTokenListProps = {
+  accessTokens: IResGetAccessToken[];
+  deleteHandler?: (tokenId: string) => void;
+}
+export const AccessTokenList = React.memo((props: AccessTokenListProps): JSX.Element => {
+
+
+  const { t } = useTranslation();
+  const { accessTokens, deleteHandler } = props;
+  const [tokenToDelete, setTokenToDelete] = useState<string | null>(null);
+
+  const handleDeleteClick = (tokenId: string) => {
+    setTokenToDelete(tokenId);
+  };
+
+  const handleConfirmDelete = () => {
+    if (tokenToDelete != null && deleteHandler != null) {
+      deleteHandler(tokenToDelete);
+      setTokenToDelete(null);
+    }
+  };
+
+  const toggleModal = () => {
+    setTokenToDelete(null);
+  };
+
+  return (
+    <>
+      <div className="table">
+        <table className="table table-bordered">
+          <thead>
+            <tr>
+              <th>{t('page_me_access_token.description')}</th>
+              <th>{t('page_me_access_token.expiredAt')}</th>
+              <th>{t('page_me_access_token.scope')}</th>
+              <th>{t('page_me_access_token.action')}</th>
+            </tr>
+          </thead>
+          <tbody>
+            {(accessTokens.length === 0)
+              ? (
+                <tr>
+                  <td colSpan={4} className="text-center">
+                    {t('page_me_access_token.no_tokens_found')}
+                  </td>
+                </tr>
+              )
+              : (
+                <>{
+                  accessTokens.map(token => (
+                    <tr key={token._id}>
+                      <td className="text-break">{token.description}</td>
+                      <td>{token.expiredAt.toString().split('T')[0]}</td>
+                      <td>{token.scopes.join(', ')}</td>
+                      <td>
+                        <button
+                          className="btn btn-danger"
+                          type="button"
+                          onClick={() => handleDeleteClick(token._id)}
+                          data-testid="grw-accesstoken-delete-button"
+                        >
+                          {t('Delete')}
+                        </button>
+                      </td>
+                    </tr>
+                  ))
+                }
+                </>
+              )}
+          </tbody>
+        </table>
+      </div>
+
+      {/* Confirmation Modal using Reactstrap */}
+      <Modal isOpen={tokenToDelete !== null} toggle={toggleModal} centered>
+        <ModalHeader tag="h4" toggle={toggleModal} className="bg-danger text-white">
+          <span className="material-symbols-outlined me-1">warning</span>
+          {t('Warning')}
+        </ModalHeader>
+        <ModalBody>
+          <p>{t('page_me_access_token.modal.message')}</p>
+          <p className="text-danger fw-bold">{t('page_me_access_token.modal.alert')}</p>
+        </ModalBody>
+        <ModalFooter>
+          <Button color="secondary" onClick={toggleModal} data-testid="grw-accesstoken-cancel-button-in-modal">
+            {t('Cancel')}
+          </Button>
+          <Button color="danger" onClick={handleConfirmDelete} data-testid="grw-accesstoken-delete-button-in-modal">
+            {t('page_me_access_token.modal.delete_token')}
+          </Button>
+        </ModalFooter>
+      </Modal>
+    </>
+  );
+});
+AccessTokenList.displayName = 'AccessTokenList';

+ 36 - 0
apps/app/src/client/components/Me/AccessTokenScopeList.module.scss

@@ -0,0 +1,36 @@
+$baseMargin: 20px;
+
+.access-token-scope-list :global {
+  .indentation {
+    &.indentation-level-1 {
+      margin-left: $baseMargin;
+    }
+    &.indentation-level-2 {
+      margin-left: $baseMargin * 2;
+    }
+    &.indentation-level-3 {
+      margin-left: $baseMargin * 3;
+    }
+    &.indentation-level-4 {
+      margin-left: $baseMargin * 4;
+    }
+    &.indentation-level-5 {
+      margin-left: $baseMargin * 5;
+    }
+    &.indentation-level-6 {
+      margin-left: $baseMargin * 6;
+    }
+    &.indentation-level-7 {
+      margin-left: $baseMargin * 7;
+    }
+    &.indentation-level-8 {
+      margin-left: $baseMargin * 8;
+    }
+    &.indentation-level-9 {
+      margin-left: $baseMargin * 9;
+    }
+    &.indentation-level-10 {
+      margin-left: $baseMargin * 10;
+    }
+  }
+}

+ 89 - 0
apps/app/src/client/components/Me/AccessTokenScopeList.tsx

@@ -0,0 +1,89 @@
+import React from 'react';
+
+import type { Scope } from '@growi/core/dist/interfaces';
+import { useTranslation } from 'next-i18next';
+import type { UseFormRegisterReturn } from 'react-hook-form';
+
+import { useIsDeviceLargerThanMd } from '~/stores/ui';
+
+
+import styles from './AccessTokenScopeList.module.scss';
+
+const moduleClass = styles['access-token-scope-list'] ?? '';
+
+interface scopeObject {
+  [key: string]: Scope | scopeObject;
+}
+
+interface AccessTokenScopeListProps {
+  scopeObject: scopeObject;
+  register: UseFormRegisterReturn<'scopes'>;
+  disabledScopes: Set<Scope>
+  level?: number;
+}
+
+/**
+ * Renders the permission object recursively as nested checkboxes.
+ */
+export const AccessTokenScopeList: React.FC<AccessTokenScopeListProps> = ({
+  scopeObject,
+  register,
+  disabledScopes,
+  level = 1,
+}) => {
+
+  const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
+
+  // Convert object into an array to determine "first vs. non-first" elements
+  const entries = Object.entries(scopeObject);
+  const { t } = useTranslation('commons');
+
+  return (
+    <>
+      {entries.map(([scopeKey, scopeValue], idx) => {
+        const showHr = (level === 1 || level === 2) && idx !== 0;
+
+        if (typeof scopeValue === 'object') {
+          return (
+            <div key={scopeKey} className={moduleClass}>
+              {showHr && <hr className="my-1" />}
+              <div className="my-1 row">
+                <div className="col-md-5 ">
+                  <label className={`form-check-label fw-bold indentation indentation-level-${level}`}>{scopeKey}</label>
+                </div>
+              </div>
+
+              {/* Render recursively */}
+              <AccessTokenScopeList
+                scopeObject={scopeValue as scopeObject}
+                register={register}
+                level={level + 1}
+                disabledScopes={disabledScopes}
+              />
+            </div>
+          );
+        }
+        // If it's a string, render a checkbox
+        return (
+          <div key={scopeKey} className={`row my-1 ${moduleClass}`}>
+            <div className="col-md-5 indentation">
+              <input
+                data-testid={`grw-accesstoken-checkbox-${scopeValue}`}
+                className={`form-check-input indentation indentation-level-${level}`}
+                type="checkbox"
+                id={scopeValue as string}
+                disabled={disabledScopes.has(scopeValue)}
+                value={scopeValue as string}
+                {...register}
+              />
+              <label className="form-check-label ms-2" htmlFor={scopeValue as string}>
+                {scopeKey}
+              </label>
+            </div>
+            <div className={`col form-text ${isDeviceLargerThanMd ? '' : 'text-end'}`}>{t(`accesstoken_scopes_desc.${scopeKey.replace(/:/g, '.')}`)}</div>
+          </div>
+        );
+      })}
+    </>
+  );
+};

+ 41 - 0
apps/app/src/client/components/Me/AccessTokenScopeSelect.tsx

@@ -0,0 +1,41 @@
+import React, { useEffect, useState, useMemo } from 'react';
+
+import type { Scope } from '@growi/core/dist/interfaces';
+import { SCOPE } from '@growi/core/dist/interfaces';
+import type { UseFormRegisterReturn } from 'react-hook-form';
+
+import { extractScopes, getDisabledScopes, parseScopes } from '~/client/util/scope-util';
+import { useIsAdmin } from '~/stores-universal/context';
+
+import { AccessTokenScopeList } from './AccessTokenScopeList';
+
+/**
+ * Props for AccessTokenScopeSelect
+ */
+type AccessTokenScopeSelectProps = {
+  /** React Hook Form's register function for a field named "scopes" */
+  register: UseFormRegisterReturn<'scopes'>;
+  selectedScopes: Scope[];
+};
+
+/**
+ * Displays a list of permissions in a recursive, nested checkbox interface.
+ */
+export const AccessTokenScopeSelect: React.FC<AccessTokenScopeSelectProps> = ({ register, selectedScopes }) => {
+  const [disabledScopes, setDisabledScopes] = useState<Set<Scope>>(new Set());
+  const { data: isAdmin } = useIsAdmin();
+
+  const ScopesMap = useMemo(() => parseScopes({ scopes: SCOPE, isAdmin }), [isAdmin]);
+  const extractedScopes = useMemo(() => extractScopes(ScopesMap), [ScopesMap]);
+
+  useEffect(() => {
+    const disabledSet = getDisabledScopes(selectedScopes, extractedScopes);
+    setDisabledScopes(disabledSet);
+  }, [selectedScopes, extractedScopes]);
+
+  return (
+    <div className="border rounded">
+      <AccessTokenScopeList scopeObject={ScopesMap} register={register} disabledScopes={disabledScopes} />
+    </div>
+  );
+};

+ 135 - 0
apps/app/src/client/components/Me/AccessTokenSettings.tsx

@@ -0,0 +1,135 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import CopyToClipboard from 'react-copy-to-clipboard';
+
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import type { IAccessTokenInfo } from '~/interfaces/access-token';
+import { useSWRxAccessToken } from '~/stores/personal-settings';
+
+import { AccessTokenForm } from './AccessTokenForm';
+import { AccessTokenList } from './AccessTokenList';
+
+
+const NewTokenDisplay = React.memo(({ newToken, closeNewTokenDisplay }: { newToken?: string, closeNewTokenDisplay: () => void }): JSX.Element => {
+
+  const { t } = useTranslation();
+
+  // Handle successful copy
+  const handleCopySuccess = useCallback(() => {
+    toastSuccess(t('page_me_access_token.new_token.copy_to_clipboard'));
+  }, [t]);
+
+  if (newToken == null) {
+    return <></>;
+  }
+
+  return (
+    <div className="alert alert-success mb-4" role="alert" data-testid="grw-accesstoken-new-token-display">
+      <div className="d-flex justify-content-between align-items-center mb-2">
+        <h5 className="mb-0">
+          {t('page_me_access_token.new_token.title')}
+        </h5>
+        <button
+          type="button"
+          className="btn-close"
+          onClick={closeNewTokenDisplay}
+          aria-label="Close"
+        >
+        </button>
+      </div>
+
+      <p className="fw-bold mb-2">{t('page_me_access_token.new_token.message')}</p>
+
+      <div className="input-group mb-2">
+        <input
+          type="text"
+          className="form-control font-monospace"
+          value={newToken}
+          readOnly
+          data-vrt-blackout
+        />
+        <CopyToClipboard text={newToken} onCopy={handleCopySuccess}>
+          <button
+            className="btn btn-outline-secondary"
+            type="button"
+          >
+            <span className="material-symbols-outlined">content_copy</span>
+          </button>
+        </CopyToClipboard>
+      </div>
+    </div>
+  );
+});
+
+export const AccessTokenSettings = React.memo((): JSX.Element => {
+
+  const { t } = useTranslation();
+
+  const [isFormOpen, setIsFormOpen] = React.useState<boolean>(false);
+  const toggleFormOpen = useCallback(() => {
+    setIsFormOpen(prev => !prev);
+  }, []);
+
+  const [newToken, setNewToken] = React.useState<string | undefined>(undefined);
+
+  const {
+    data: accessTokens, mutate, generateAccessToken, deleteAccessToken,
+  } = useSWRxAccessToken();
+
+  const closeNewTokenDisplay = useCallback(() => {
+    setNewToken(undefined);
+  }, []);
+
+  const submitHandler = useCallback(async(info: IAccessTokenInfo) => {
+    try {
+      const result = await generateAccessToken(info);
+      mutate();
+      setIsFormOpen(false);
+
+      // Store the newly generated token to display to the user
+      if (result?.token) {
+        setNewToken(result.token);
+      }
+
+      toastSuccess(t('toaster.add_succeeded', { target: t('page_me_access_token.access_token'), ns: 'commons' }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, generateAccessToken, mutate, setIsFormOpen]);
+
+  const deleteHandler = useCallback(async(tokenId: string) => {
+    try {
+      await deleteAccessToken(tokenId);
+      mutate();
+      toastSuccess(t('toaster.delete_succeeded', { target: t('page_me_access_token.access_token'), ns: 'commons' }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [deleteAccessToken, mutate, t]);
+
+  return (
+    <>
+
+      <div className="container p-0">
+
+        <NewTokenDisplay newToken={newToken} closeNewTokenDisplay={closeNewTokenDisplay} />
+        <AccessTokenList accessTokens={accessTokens ?? []} deleteHandler={deleteHandler} />
+
+        <button
+          className="btn btn-outline-secondary d-block mx-auto px-5"
+          type="button"
+          onClick={toggleFormOpen}
+          data-testid="btn-accesstoken-toggleform"
+        >
+          {isFormOpen ? t('Close') : t('New')}
+        </button>
+        {isFormOpen && <AccessTokenForm submitHandler={submitHandler} />}
+      </div>
+    </>
+  );
+});
+
+AccessTokenSettings.displayName = 'AccessTokenSettings';

+ 15 - 69
apps/app/src/client/components/Me/ApiSettings.tsx

@@ -1,89 +1,35 @@
-import React, { useCallback, type JSX } from 'react';
+import React from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
-import { apiv3Put } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useSWRxPersonalSettings, usePersonalSettings } from '~/stores/personal-settings';
+import { useCurrentUser } from '~/stores-universal/context';
+
+import { AccessTokenSettings } from './AccessTokenSettings';
+import { ApiTokenSettings } from './ApiTokenSettings';
 
 
 
 
 const ApiSettings = React.memo((): JSX.Element => {
 const ApiSettings = React.memo((): JSX.Element => {
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { mutate: mutateDatabaseData } = useSWRxPersonalSettings();
-  const { data: personalSettingsData } = usePersonalSettings();
-
-  const submitHandler = useCallback(async() => {
-
-    try {
-      await apiv3Put('/personal-setting/api-token');
-      mutateDatabaseData();
+  const { data: currentUser, isLoading: isLoadingCurrentUserData } = useCurrentUser();
 
 
-      toastSuccess(t('toaster.update_successed', { target: t('page_me_apitoken.api_token'), ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-
-  }, [mutateDatabaseData, t]);
+  const shouldHideAccessTokenSettings = isLoadingCurrentUserData || !currentUser?.readOnly;
 
 
   return (
   return (
     <>
     <>
-
-      <h2 className="border-bottom pb-2 my-4 fs-4">{ t('API Token Settings') }</h2>
-
-      <div className="row mb-3">
-        <label htmlFor="apiToken" className="col-md-3 text-md-end col-form-label">{t('Current API Token')}</label>
-        <div className="col-md-6">
-          {personalSettingsData?.apiToken != null
-            ? (
-              <input
-                data-testid="grw-api-settings-input"
-                data-vrt-blackout
-                className="form-control"
-                type="text"
-                name="apiToken"
-                value={personalSettingsData.apiToken}
-                readOnly
-              />
-            )
-            : (
-              <p>
-                { t('page_me_apitoken.notice.apitoken_issued') }
-              </p>
-            )}
-        </div>
+      <div className="mt-4">
+        <h2 className="border-bottom pb-2 my-4 fs-4">{ t('API Token Settings') }</h2>
+        <ApiTokenSettings />
       </div>
       </div>
 
 
-
-      <div className="row">
-        <div className="offset-lg-2 col-lg-7">
-
-          <p className="alert alert-warning">
-            { t('page_me_apitoken.notice.update_token1') }<br />
-            { t('page_me_apitoken.notice.update_token2') }
-          </p>
-
-        </div>
-      </div>
-
-      <div className="row my-3">
-        <div className="offset-4 col-5">
-          <button
-            data-testid="grw-api-settings-update-button"
-            type="button"
-            className="btn btn-primary text-nowrap"
-            onClick={submitHandler}
-          >
-            {t('Update API Token')}
-          </button>
+      {shouldHideAccessTokenSettings && (
+        <div className="mt-4">
+          <h2 className="border-bottom pb-2 my-4 fs-4">{ t('Access Token Settings') }</h2>
+          <AccessTokenSettings />
         </div>
         </div>
-      </div>
-
+      )}
     </>
     </>
-
   );
   );
-
 });
 });
 
 
 ApiSettings.displayName = 'ApiSettings';
 ApiSettings.displayName = 'ApiSettings';

+ 85 - 0
apps/app/src/client/components/Me/ApiTokenSettings.tsx

@@ -0,0 +1,85 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import {
+  apiv3Put,
+} from '~/client/util/apiv3-client';
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import { usePersonalSettings, useSWRxPersonalSettings } from '~/stores/personal-settings';
+
+
+export const ApiTokenSettings = React.memo((): JSX.Element => {
+
+  const { t } = useTranslation();
+  const { mutate: mutateDatabaseData } = useSWRxPersonalSettings();
+  const { data: personalSettingsData } = usePersonalSettings();
+
+  const submitHandler = useCallback(async() => {
+
+    try {
+      await apiv3Put('/personal-setting/api-token');
+      mutateDatabaseData();
+
+      toastSuccess(t('toaster.update_successed', { target: t('page_me_apitoken.api_token'), ns: 'commons' }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  }, [mutateDatabaseData, t]);
+
+  return (
+    <>
+      <div className="row mb-3">
+        <label htmlFor="apiToken" className="col-md-3 text-md-end col-form-label">{t('Current API Token')}</label>
+        <div className="col-md-6">
+          {personalSettingsData?.apiToken != null
+            ? (
+              <input
+                data-testid="grw-api-settings-input"
+                data-vrt-blackout
+                className="form-control"
+                type="text"
+                name="apiToken"
+                value={personalSettingsData.apiToken}
+                readOnly
+              />
+            )
+            : (
+              <p>
+                { t('page_me_apitoken.notice.apitoken_issued') }
+              </p>
+            )}
+        </div>
+      </div>
+
+
+      <div className="row">
+        <div className="offset-lg-2 col-lg-7">
+
+          <p className="alert alert-warning">
+            { t('page_me_apitoken.notice.update_token1') }<br />
+            { t('page_me_apitoken.notice.update_token2') }
+          </p>
+
+        </div>
+      </div>
+
+      <div className="row my-3">
+        <div className="offset-4 col-5">
+          <button
+            data-testid="grw-api-settings-update-button"
+            type="button"
+            className="btn btn-primary text-nowrap"
+            onClick={submitHandler}
+          >
+            {t('Update API Token')}
+          </button>
+        </div>
+      </div>
+
+    </>
+
+  );
+});

+ 137 - 0
apps/app/src/client/util/scope-util.test.ts

@@ -0,0 +1,137 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
+import { describe, it, expect } from 'vitest';
+
+import { parseScopes, getDisabledScopes, extractScopes } from './scope-util';
+
+describe('scope-util', () => {
+
+  const mockScopes = {
+    READ: {
+      USER: 'read:user',
+      ADMIN: {
+        SETTING: 'read:admin:setting',
+        ALL: 'read:admin:all',
+      },
+      ALL: 'read:all',
+    },
+    WRITE: {
+      USER: 'write:user',
+      ADMIN: {
+        SETTING: 'write:admin:setting',
+        ALL: 'write:admin:all',
+      },
+      ALL: 'write:all',
+    },
+  };
+
+  it('should parse scopes correctly for non-admin', () => {
+    const result = parseScopes({ scopes: mockScopes, isAdmin: false });
+
+    // Check that admin scopes are excluded
+    expect(result.ADMIN).toBeUndefined();
+    expect(result.ALL).toBeUndefined();
+
+    // Check that user scopes are included
+    expect(result.USER).toBeDefined();
+    expect(result.USER['read:user']).toBe('read:user');
+    expect(result.USER['write:user']).toBe('write:user');
+  });
+
+  it('should include admin scopes for admin users', () => {
+    const result = parseScopes({ scopes: mockScopes, isAdmin: true });
+
+    // Check that admin scopes are included
+    expect(result.ADMIN).toBeDefined();
+    expect(result.ALL).toBeDefined();
+
+    // Check admin settings
+    expect(result.ADMIN['admin:setting']['read:admin:setting']).toBe('read:admin:setting');
+    expect(result.ADMIN['admin:setting']['write:admin:setting']).toBe('write:admin:setting');
+
+    // Check ALL category
+    expect(result.ALL['read:all']).toBe('read:all');
+    expect(result.ALL['write:all']).toBe('write:all');
+  });
+
+  it('should return empty set when no scopes are selected', () => {
+    const result = getDisabledScopes([], ['read:user', 'write:user']);
+    expect(result.size).toBe(0);
+  });
+
+  it('should disable specific scopes when a wildcard is selected', () => {
+    const selectedScopes = [SCOPE.READ.ALL];
+    const availableScopes = [
+      SCOPE.READ.FEATURES.PAGE,
+      SCOPE.READ.FEATURES.ATTACHMENT,
+      SCOPE.WRITE.FEATURES.PAGE,
+      SCOPE.READ.ALL,
+    ];
+
+    const result = getDisabledScopes(selectedScopes, availableScopes);
+
+    // Should disable all read: scopes except the wildcard itself
+    expect(result.has(SCOPE.READ.FEATURES.PAGE)).toBe(true);
+    expect(result.has(SCOPE.READ.FEATURES.ATTACHMENT)).toBe(true);
+    expect(result.has(SCOPE.WRITE.FEATURES.PAGE)).toBe(false);
+    expect(result.has(SCOPE.READ.ALL)).toBe(false);
+  });
+
+  it('should handle multiple wildcard selections', () => {
+    const selectedScopes = [SCOPE.READ.ALL, SCOPE.WRITE.ALL];
+    const availableScopes = [
+      SCOPE.READ.FEATURES.PAGE, SCOPE.READ.FEATURES.ATTACHMENT, SCOPE.READ.ALL,
+      SCOPE.WRITE.FEATURES.PAGE, SCOPE.WRITE.FEATURES.ATTACHMENT, SCOPE.WRITE.ALL,
+    ];
+
+    const result = getDisabledScopes(selectedScopes, availableScopes);
+
+    // Should disable all specific scopes under both wildcards
+    expect(result.has(SCOPE.READ.FEATURES.PAGE)).toBe(true);
+    expect(result.has(SCOPE.READ.FEATURES.ATTACHMENT)).toBe(true);
+    expect(result.has(SCOPE.WRITE.FEATURES.PAGE)).toBe(true);
+    expect(result.has(SCOPE.WRITE.FEATURES.ATTACHMENT)).toBe(true);
+    expect(result.has(SCOPE.READ.ALL)).toBe(false);
+    expect(result.has(SCOPE.WRITE.ALL)).toBe(false);
+  });
+
+  it('should extract all scope strings from a nested object', () => {
+    const scopeObj = {
+      USER: {
+        'read:user': 'read:user',
+        'write:user': 'write:user',
+      },
+      ADMIN: {
+        'ADMIN:SETTING': {
+          'read:admin:setting': 'read:admin:setting',
+          'write:admin:setting': 'write:admin:setting',
+        },
+      },
+    };
+
+    const result = extractScopes(scopeObj);
+
+    expect(result).toContain('read:user');
+    expect(result).toContain('write:user');
+    expect(result).toContain('read:admin:setting');
+    expect(result).toContain('write:admin:setting');
+    expect(result.length).toBe(4);
+  });
+
+  it('should return empty array for empty object', () => {
+    const result = extractScopes({});
+    expect(result).toEqual([]);
+  });
+
+  it('should handle objects with no string values', () => {
+    const scopeObj = {
+      level1: {
+        level2: {
+          level3: {},
+        },
+      },
+    };
+
+    const result = extractScopes(scopeObj);
+    expect(result).toEqual([]);
+  });
+});

+ 143 - 0
apps/app/src/client/util/scope-util.ts

@@ -0,0 +1,143 @@
+import { ALL_SIGN, type Scope } from '@growi/core/dist/interfaces';
+
+
+// Data structure for the final merged scopes
+interface ScopeMap {
+  [key: string]: Scope | ScopeMap;
+}
+
+// Input object with arbitrary action keys (e.g., READ, WRITE)
+type ScopesInput = Record<string, any>;
+
+
+function parseSubScope(
+    parentKey: string,
+    subObjForActions: Record<string, any>,
+    actions: string[],
+): ScopeMap {
+  const result: ScopeMap = {};
+
+  for (const action of actions) {
+    if (typeof subObjForActions[action] === 'string') {
+      result[`${action.toLowerCase()}:${parentKey.toLowerCase()}`] = subObjForActions[action];
+      subObjForActions[action] = undefined;
+    }
+  }
+
+  const childKeys = new Set<string>();
+  for (const action of actions) {
+    const obj = subObjForActions[action];
+    if (obj && typeof obj === 'object') {
+      Object.keys(obj).forEach(k => childKeys.add(k));
+    }
+  }
+
+  for (const ck of childKeys) {
+    if (ck === 'ALL') {
+      for (const action of actions) {
+        const val = subObjForActions[action]?.[ck];
+        if (typeof val === 'string') {
+          result[`${action.toLowerCase()}:${parentKey.toLowerCase()}:all`] = val as Scope;
+        }
+      }
+      continue;
+    }
+
+    const newKey = `${parentKey}:${ck}`;
+    const childSubObj: Record<string, any> = {};
+    for (const action of actions) {
+      childSubObj[action] = subObjForActions[action]?.[ck];
+    }
+
+    result[newKey] = parseSubScope(newKey, childSubObj, actions);
+  }
+
+  return result;
+}
+
+export function parseScopes({ scopes, isAdmin = false }: { scopes: ScopesInput ; isAdmin?: boolean }): ScopeMap {
+  const actions = Object.keys(scopes);
+  const topKeys = new Set<string>();
+
+  // Collect all top-level keys (e.g., ALL, ADMIN, USER) across all actions
+  for (const action of actions) {
+    Object.keys(scopes[action] || {}).forEach(k => topKeys.add(k));
+  }
+
+  const result: ScopeMap = {};
+
+  for (const key of topKeys) {
+    // Skip 'ADMIN' key if isAdmin is true
+    if (!isAdmin && (key === 'ADMIN' || key === 'ALL')) {
+      continue;
+    }
+
+    if (key === 'ALL') {
+      const allObj: ScopeMap = {};
+      for (const action of actions) {
+        const val = scopes[action]?.[key];
+        if (typeof val === 'string') {
+          allObj[`${action.toLowerCase()}:all`] = val as Scope;
+        }
+      }
+      result.ALL = allObj;
+    }
+    else {
+      const subObjForActions: Record<string, any> = {};
+      for (const action of actions) {
+        subObjForActions[action] = scopes[action]?.[key];
+      }
+      result[key] = parseSubScope(key, subObjForActions, actions);
+    }
+  }
+
+  return result;
+}
+
+/**
+ * Determines which scopes should be disabled based on wildcard selections
+ */
+export function getDisabledScopes(selectedScopes: Scope[], availableScopes: string[]): Set<Scope> {
+  const disabledSet = new Set<Scope>();
+
+
+  // If no selected scopes, return empty set
+  if (!selectedScopes || selectedScopes.length === 0) {
+    return disabledSet;
+  }
+
+  selectedScopes.forEach((scope) => {
+    // Check if the scope is in the form `xxx:*`
+    if (scope.endsWith(`:${ALL_SIGN}`)) {
+      // Convert something like `read:*` into the prefix `read:`
+      const prefix = scope.replace(`:${ALL_SIGN}`, ':');
+
+      // Disable all scopes that start with the prefix (but are not the selected scope itself)
+      availableScopes.forEach((s: Scope) => {
+        if (s.startsWith(prefix) && s !== scope) {
+          disabledSet.add(s);
+        }
+      });
+    }
+  });
+
+  return disabledSet;
+}
+
+/**
+ * Extracts all scope strings from a nested ScopeMap object
+ */
+export function extractScopes(obj: Record<string, any>): string[] {
+  let result: string[] = [];
+
+  Object.values(obj).forEach((value) => {
+    if (typeof value === 'string') {
+      result.push(value);
+    }
+    else if (typeof value === 'object' && !Array.isArray(value)) {
+      result = result.concat(extractScopes(value));
+    }
+  });
+
+  return result;
+}

+ 27 - 8
apps/app/src/components/PageView/PageView.tsx

@@ -46,8 +46,6 @@ export const PageView = (props: Props): JSX.Element => {
 
 
   const commentsContainerRef = useRef<HTMLDivElement>(null);
   const commentsContainerRef = useRef<HTMLDivElement>(null);
 
 
-  const [isCommentsLoaded, setCommentsLoaded] = useState(false);
-
   const {
   const {
     pagePath, rendererConfig, className,
     pagePath, rendererConfig, className,
   } = props;
   } = props;
@@ -69,20 +67,42 @@ export const PageView = (props: Props): JSX.Element => {
   const markdown = page?.revision?.body;
   const markdown = page?.revision?.body;
   const isSlide = useSlidesByFrontmatter(markdown, rendererConfig.isEnabledMarp);
   const isSlide = useSlidesByFrontmatter(markdown, rendererConfig.isEnabledMarp);
 
 
+
   // ***************************  Auto Scroll  ***************************
   // ***************************  Auto Scroll  ***************************
   useEffect(() => {
   useEffect(() => {
+    if (currentPageId == null) {
+      return;
+    }
+
     // do nothing if hash is empty
     // do nothing if hash is empty
     const { hash } = window.location;
     const { hash } = window.location;
     if (hash.length === 0) {
     if (hash.length === 0) {
       return;
       return;
     }
     }
 
 
-    const targetId = hash.slice(1);
+    const contentContainer = document.getElementById('page-view-content-container');
+    if (contentContainer == null) return;
+
+    const targetId = decodeURIComponent(hash.slice(1));
+    const target = document.getElementById(targetId);
+    if (target != null) {
+      target.scrollIntoView();
+      return;
+    }
+
+    const observer = new MutationObserver(() => {
+      const target = document.getElementById(targetId);
+      if (target != null) {
+        target.scrollIntoView();
+        observer.disconnect();
+      }
+    });
+
+    observer.observe(contentContainer, { childList: true, subtree: true });
 
 
-    const target = document.getElementById(decodeURIComponent(targetId));
-    target?.scrollIntoView();
+    return () => observer.disconnect();
+  }, [currentPageId]);
 
 
-  }, [isCommentsLoaded]);
   // *******************************  end  *******************************
   // *******************************  end  *******************************
 
 
   const specialContents = useMemo(() => {
   const specialContents = useMemo(() => {
@@ -141,7 +161,6 @@ export const PageView = (props: Props): JSX.Element => {
                 pageId={page._id}
                 pageId={page._id}
                 pagePath={pagePath}
                 pagePath={pagePath}
                 revision={page.revision}
                 revision={page.revision}
-                onLoaded={() => setCommentsLoaded(true)}
               />
               />
             </div>
             </div>
           ) }
           ) }
@@ -164,7 +183,7 @@ export const PageView = (props: Props): JSX.Element => {
       {specialContents == null && (
       {specialContents == null && (
         <>
         <>
           {(isUsersHomepagePath && page?.creator != null) && <UserInfo author={page.creator} />}
           {(isUsersHomepagePath && page?.creator != null) && <UserInfo author={page.creator} />}
-          <div className="flex-expand-vert">
+          <div id="page-view-content-container" className="flex-expand-vert">
             <Contents />
             <Contents />
           </div>
           </div>
         </>
         </>

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

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

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

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

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

@@ -3,7 +3,9 @@ import express from 'express';
 import { body, query } from 'express-validator';
 import { body, query } from 'express-validator';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
+import { SCOPE } from '@growi/core/dist/interfaces';
 import type Crowi from '~/server/crowi';
 import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 
 
 import { GrowiPlugin } from '../../../models';
 import { GrowiPlugin } from '../../../models';
@@ -30,7 +32,7 @@ module.exports = (crowi: Crowi): Router => {
 
 
   const router = express.Router();
   const router = express.Router();
 
 
-  router.get('/', loginRequiredStrictly, adminRequired, async(req: Request, res: ApiV3Response) => {
+  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.PLUGIN]), loginRequiredStrictly, adminRequired, async(req: Request, res: ApiV3Response) => {
     try {
     try {
       const data = await GrowiPlugin.find({});
       const data = await GrowiPlugin.find({});
       return res.apiv3({ plugins: data });
       return res.apiv3({ plugins: data });
@@ -40,6 +42,7 @@ module.exports = (crowi: Crowi): Router => {
     }
     }
   });
   });
 
 
+
   /**
   /**
    * @swagger
    * @swagger
    *
    *
@@ -79,17 +82,18 @@ module.exports = (crowi: Crowi): Router => {
    *                   description: The name of the installed plugin
    *                   description: The name of the installed plugin
    *
    *
    */
    */
-  router.post('/', loginRequiredStrictly, adminRequired, validator.pluginFormValueisRequired, async(req: Request, res: ApiV3Response) => {
-    const { pluginInstallerForm: formValue } = req.body;
-
-    try {
-      const pluginName = await growiPluginService.install(formValue);
-      return res.apiv3({ pluginName });
-    }
-    catch (err) {
-      return res.apiv3Err(err);
-    }
-  });
+  router.post('/', accessTokenParser([SCOPE.WRITE.ADMIN.PLUGIN]), loginRequiredStrictly, adminRequired, validator.pluginFormValueisRequired,
+    async(req: Request, res: ApiV3Response) => {
+      const { pluginInstallerForm: formValue } = req.body;
+
+      try {
+        const pluginName = await growiPluginService.install(formValue);
+        return res.apiv3({ pluginName });
+      }
+      catch (err) {
+        return res.apiv3Err(err);
+      }
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -119,31 +123,33 @@ module.exports = (crowi: Crowi): Router => {
    *                   type: string
    *                   type: string
    *                   description: The name of the activated plugin
    *                   description: The name of the activated plugin
    */
    */
-  router.put('/:id/activate', loginRequiredStrictly, adminRequired, validator.pluginIdisRequired, async(req: Request, res: ApiV3Response) => {
-    const { id } = req.params;
-    const pluginId = new ObjectID(id);
-
-    try {
-      const pluginName = await GrowiPlugin.activatePlugin(pluginId);
-      return res.apiv3({ pluginName });
-    }
-    catch (err) {
-      return res.apiv3Err(err);
-    }
-  });
-
-  router.put('/:id/deactivate', loginRequiredStrictly, adminRequired, validator.pluginIdisRequired, async(req: Request, res: ApiV3Response) => {
-    const { id } = req.params;
-    const pluginId = new ObjectID(id);
-
-    try {
-      const pluginName = await GrowiPlugin.deactivatePlugin(pluginId);
-      return res.apiv3({ pluginName });
-    }
-    catch (err) {
-      return res.apiv3Err(err);
-    }
-  });
+  router.put('/:id/activate', accessTokenParser([SCOPE.WRITE.ADMIN.PLUGIN]), loginRequiredStrictly, adminRequired, validator.pluginIdisRequired,
+    async(req: Request, res: ApiV3Response) => {
+      const { id } = req.params;
+      const pluginId = new ObjectID(id);
+
+      try {
+        const pluginName = await GrowiPlugin.activatePlugin(pluginId);
+        return res.apiv3({ pluginName });
+      }
+      catch (err) {
+        return res.apiv3Err(err);
+      }
+    });
+
+  router.put('/:id/deactivate', accessTokenParser([SCOPE.WRITE.ADMIN.PLUGIN]), loginRequiredStrictly, adminRequired, validator.pluginIdisRequired,
+    async(req: Request, res: ApiV3Response) => {
+      const { id } = req.params;
+      const pluginId = new ObjectID(id);
+
+      try {
+        const pluginName = await GrowiPlugin.deactivatePlugin(pluginId);
+        return res.apiv3({ pluginName });
+      }
+      catch (err) {
+        return res.apiv3Err(err);
+      }
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -173,18 +179,19 @@ module.exports = (crowi: Crowi): Router => {
    *                   type: string
    *                   type: string
    *                   description: The name of the removed plugin
    *                   description: The name of the removed plugin
    */
    */
-  router.delete('/:id/remove', loginRequiredStrictly, adminRequired, validator.pluginIdisRequired, async(req: Request, res: ApiV3Response) => {
-    const { id } = req.params;
-    const pluginId = new ObjectID(id);
-
-    try {
-      const pluginName = await growiPluginService.deletePlugin(pluginId);
-      return res.apiv3({ pluginName });
-    }
-    catch (err) {
-      return res.apiv3Err(err);
-    }
-  });
+  router.delete('/:id/remove', accessTokenParser([SCOPE.WRITE.ADMIN.PLUGIN]), loginRequiredStrictly, adminRequired, validator.pluginIdisRequired,
+    async(req: Request, res: ApiV3Response) => {
+      const { id } = req.params;
+      const pluginId = new ObjectID(id);
+
+      try {
+        const pluginName = await growiPluginService.deletePlugin(pluginId);
+        return res.apiv3({ pluginName });
+      }
+      catch (err) {
+        return res.apiv3Err(err);
+      }
+    });
 
 
   return router;
   return router;
 };
 };

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

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

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

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

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

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

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

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

+ 3 - 1
apps/app/src/features/openai/server/routes/edit/index.ts

@@ -1,5 +1,6 @@
 import { getIdStringForRef } from '@growi/core';
 import { getIdStringForRef } from '@growi/core';
 import type { IUserHasId } from '@growi/core/dist/interfaces';
 import type { IUserHasId } from '@growi/core/dist/interfaces';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler, Response } from 'express';
 import type { Request, RequestHandler, Response } from 'express';
 import type { ValidationChain } from 'express-validator';
 import type { ValidationChain } from 'express-validator';
@@ -8,6 +9,7 @@ import { zodResponseFormat } from 'openai/helpers/zod';
 import type { MessageDelta } from 'openai/resources/beta/threads/messages.mjs';
 import type { MessageDelta } from 'openai/resources/beta/threads/messages.mjs';
 import { z } from 'zod';
 import { z } from 'zod';
 
 
+// Necessary imports
 import type Crowi from '~/server/crowi';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
@@ -182,7 +184,7 @@ export const postMessageToEditHandlersFactory: PostMessageHandlersFactory = (cro
   ];
   ];
 
 
   return [
   return [
-    accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
+    accessTokenParser([SCOPE.WRITE.FEATURES.AI_ASSISTANT], { acceptLegacy: true }), loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
     async(req: Req, res: ApiV3Response) => {
       const {
       const {
         userMessage,
         userMessage,

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

@@ -1,4 +1,4 @@
-import { type IUserHasId } from '@growi/core';
+import { SCOPE, type IUserHasId } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
 import type { Request, RequestHandler } from 'express';
 import { type ValidationChain, query } from 'express-validator';
 import { type ValidationChain, query } from 'express-validator';
@@ -41,7 +41,7 @@ export const getRecentThreadsFactory: GetRecentThreadsFactory = (crowi) => {
   ];
   ];
 
 
   return [
   return [
-    accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
+    accessTokenParser([SCOPE.READ.FEATURES.AI_ASSISTANT], { acceptLegacy: true }), loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
     async(req: Req, res: ApiV3Response) => {
       const openaiService = getOpenaiService();
       const openaiService = getOpenaiService();
       if (openaiService == null) {
       if (openaiService == null) {

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

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

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

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

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

@@ -1,4 +1,5 @@
 import type { IUserHasId } from '@growi/core/dist/interfaces';
 import type { IUserHasId } from '@growi/core/dist/interfaces';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler, Response } from 'express';
 import type { Request, RequestHandler, Response } from 'express';
 import type { ValidationChain } from 'express-validator';
 import type { ValidationChain } from 'express-validator';
@@ -71,7 +72,7 @@ export const postMessageHandlersFactory: PostMessageHandlersFactory = (crowi) =>
   ];
   ];
 
 
   return [
   return [
-    accessTokenParser, loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
+    accessTokenParser([SCOPE.WRITE.FEATURES.AI_ASSISTANT], { acceptLegacy: true }), loginRequiredStrictly, certifyAiService, validator, apiV3FormValidator,
     async(req: Req, res: ApiV3Response) => {
     async(req: Req, res: ApiV3Response) => {
       const { aiAssistantId, threadId } = req.body;
       const { aiAssistantId, threadId } = req.body;
 
 

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

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

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

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

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

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

+ 26 - 23
apps/app/src/features/page-bulk-export/server/routes/apiv3/page-bulk-export.ts

@@ -1,3 +1,4 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request } from 'express';
 import type { Request } from 'express';
 import { Router } from 'express';
 import { Router } from 'express';
@@ -18,6 +19,7 @@ interface AuthorizedRequest extends Request {
 }
 }
 
 
 module.exports = (crowi: Crowi): Router => {
 module.exports = (crowi: Crowi): Router => {
+  const accessTokenParser = crowi.accessTokenParser;
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
 
 
   const validators = {
   const validators = {
@@ -28,30 +30,31 @@ module.exports = (crowi: Crowi): Router => {
     ],
     ],
   };
   };
 
 
-  router.post('/', loginRequiredStrictly, validators.pageBulkExport, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    const errors = validationResult(req);
-    if (!errors.isEmpty()) {
-      return res.status(400).json({ errors: errors.array() });
-    }
-
-    const { path, format, restartJob } = req.body;
-
-    try {
-      await pageBulkExportService?.createOrResetBulkExportJob(path, format, req.user, restartJob);
-      return res.apiv3({}, 204);
-    }
-    catch (err) {
-      logger.error(err);
-      if (err instanceof DuplicateBulkExportJobError) {
-        return res.apiv3Err(new ErrorV3(
-          'Duplicate bulk export job is in progress',
-          'page_export.duplicate_bulk_export_job_error', undefined,
-          { duplicateJob: { createdAt: err.duplicateJob.createdAt } },
-        ), 409);
+  router.post('/', accessTokenParser([SCOPE.WRITE.FEATURES.PAGE_BULK_EXPORT]),
+    loginRequiredStrictly, validators.pageBulkExport, async(req: AuthorizedRequest, res: ApiV3Response) => {
+      const errors = validationResult(req);
+      if (!errors.isEmpty()) {
+        return res.status(400).json({ errors: errors.array() });
       }
       }
-      return res.apiv3Err(new ErrorV3('Failed to start bulk export', 'page_export.failed_to_export'));
-    }
-  });
+
+      const { path, format, restartJob } = req.body;
+
+      try {
+        await pageBulkExportService?.createOrResetBulkExportJob(path, format, req.user, restartJob);
+        return res.apiv3({}, 204);
+      }
+      catch (err) {
+        logger.error(err);
+        if (err instanceof DuplicateBulkExportJobError) {
+          return res.apiv3Err(new ErrorV3(
+            'Duplicate bulk export job is in progress',
+            'page_export.duplicate_bulk_export_job_error', undefined,
+            { duplicateJob: { createdAt: err.duplicateJob.createdAt } },
+          ), 409);
+        }
+        return res.apiv3Err(new ErrorV3('Failed to start bulk export', 'page_export.failed_to_export'));
+      }
+    });
 
 
   return router;
   return router;
 
 

+ 1 - 11
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/index.ts

@@ -103,18 +103,8 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
    * @param isHtmlPath whether the tmp output path is for html files
    * @param isHtmlPath whether the tmp output path is for html files
    */
    */
   getTmpOutputDir(pageBulkExportJob: PageBulkExportJobDocument, isHtmlPath = false): string {
   getTmpOutputDir(pageBulkExportJob: PageBulkExportJobDocument, isHtmlPath = false): string {
-    const isGrowiCloud = configManager.getConfig('app:growiCloudUri') != null;
-    const appId = configManager.getConfig('app:growiAppIdForCloud')?.toString();
     const jobId = pageBulkExportJob._id.toString();
     const jobId = pageBulkExportJob._id.toString();
-
-    if (isGrowiCloud) {
-      if (appId == null) {
-        throw new Error('appId is required for bulk export on GROWI.cloud');
-      }
-    }
-
-    const basePath = path.join(this.tmpOutputRootDir, appId ?? '');
-    return isHtmlPath ? path.join(basePath, 'html', jobId) : path.join(basePath, jobId);
+    return isHtmlPath ? path.join(this.tmpOutputRootDir, 'html', jobId) : path.join(this.tmpOutputRootDir, jobId);
   }
   }
 
 
   /**
   /**

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

@@ -8,7 +8,9 @@ import { param, query } from 'express-validator';
 
 
 import { PLUGIN_STORING_PATH } from '~/features/growi-plugin/server/consts';
 import { PLUGIN_STORING_PATH } from '~/features/growi-plugin/server/consts';
 import { GrowiPlugin } from '~/features/growi-plugin/server/models';
 import { GrowiPlugin } from '~/features/growi-plugin/server/models';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import type Crowi from '~/server/crowi';
 import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -77,7 +79,7 @@ module.exports = (crowi: Crowi) => {
    *                       title:
    *                       title:
    *                         type: string
    *                         type: string
    */
    */
-  router.get('/', loginRequiredStrictly, validator.list, apiV3FormValidator, async(req, res: ApiV3Response) => {
+  router.get('/', accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequiredStrictly, validator.list, apiV3FormValidator, async(req, res: ApiV3Response) => {
     const { includeInvalidTemplates } = req.query;
     const { includeInvalidTemplates } = req.query;
 
 
     // scan preset templates
     // scan preset templates
@@ -147,21 +149,23 @@ module.exports = (crowi: Crowi) => {
    *                 markdown:
    *                 markdown:
    *                   type: string
    *                   type: string
    */
    */
-  router.get('/preset-templates/:templateId/:locale', loginRequiredStrictly, validator.get, apiV3FormValidator, async(req, res: ApiV3Response) => {
-    const {
-      templateId, locale,
-    } = req.params;
+  router.get('/preset-templates/:templateId/:locale', accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequiredStrictly,
+    validator.get, apiV3FormValidator,
+    async(req, res: ApiV3Response) => {
+      const {
+        templateId, locale,
+      } = req.params;
 
 
-    const presetTemplatesRoot = resolveFromRoot('node_modules/@growi/preset-templates');
+      const presetTemplatesRoot = resolveFromRoot('node_modules/@growi/preset-templates');
 
 
-    try {
-      const markdown = await getMarkdown(presetTemplatesRoot, templateId, locale);
-      return res.apiv3({ markdown });
-    }
-    catch (err) {
-      res.apiv3Err(err);
-    }
-  });
+      try {
+        const markdown = await getMarkdown(presetTemplatesRoot, templateId, locale);
+        return res.apiv3({ markdown });
+      }
+      catch (err) {
+        res.apiv3Err(err);
+      }
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -209,23 +213,24 @@ module.exports = (crowi: Crowi) => {
    *                 markdown:
    *                 markdown:
    *                   type: string
    *                   type: string
    */
    */
-  router.get('/plugin-templates/:organizationId/:reposId/:templateId/:locale', loginRequiredStrictly, validator.get, apiV3FormValidator, async(
-      req, res: ApiV3Response,
-  ) => {
-    const {
-      organizationId, reposId, templateId, locale,
-    } = req.params;
+  router.get('/plugin-templates/:organizationId/:reposId/:templateId/:locale', accessTokenParser([SCOPE.READ.FEATURES.PAGE]),
+    loginRequiredStrictly, validator.get, apiV3FormValidator, async(
+        req, res: ApiV3Response,
+    ) => {
+      const {
+        organizationId, reposId, templateId, locale,
+      } = req.params;
 
 
-    const pluginRoot = path.join(PLUGIN_STORING_PATH, `${organizationId}/${reposId}`);
+      const pluginRoot = path.join(PLUGIN_STORING_PATH, `${organizationId}/${reposId}`);
 
 
-    try {
-      const markdown = await getMarkdown(pluginRoot, templateId, locale);
-      return res.apiv3({ markdown });
-    }
-    catch (err) {
-      res.apiv3Err(err);
-    }
-  });
+      try {
+        const markdown = await getMarkdown(pluginRoot, templateId, locale);
+        return res.apiv3({ markdown });
+      }
+      catch (err) {
+        res.apiv3Err(err);
+      }
+    });
 
 
   return router;
   return router;
 };
 };

+ 16 - 0
apps/app/src/interfaces/access-token.ts

@@ -0,0 +1,16 @@
+import type { Scope } from '@growi/core/dist/interfaces';
+
+export type IAccessTokenInfo = {
+  expiredAt: Date,
+  description: string,
+  scopes: Scope[],
+}
+
+export type IResGenerateAccessToken = IAccessTokenInfo & {
+  token: string,
+  _id: string,
+}
+
+export type IResGetAccessToken = IAccessTokenInfo & {
+  _id: string,
+}

+ 6 - 3
apps/app/src/interfaces/activity.ts

@@ -25,7 +25,8 @@ const ACTION_USER_IMAGE_TYPE_UPDATE = 'USER_IMAGE_TYPE_UPDATE';
 const ACTION_USER_LDAP_ACCOUNT_ASSOCIATE = 'USER_LDAP_ACCOUNT_ASSOCIATE';
 const ACTION_USER_LDAP_ACCOUNT_ASSOCIATE = 'USER_LDAP_ACCOUNT_ASSOCIATE';
 const ACTION_USER_LDAP_ACCOUNT_DISCONNECT = 'USER_LDAP_ACCOUNT_DISCONNECT';
 const ACTION_USER_LDAP_ACCOUNT_DISCONNECT = 'USER_LDAP_ACCOUNT_DISCONNECT';
 const ACTION_USER_PASSWORD_UPDATE = 'USER_PASSWORD_UPDATE';
 const ACTION_USER_PASSWORD_UPDATE = 'USER_PASSWORD_UPDATE';
-const ACTION_USER_API_TOKEN_UPDATE = 'USER_API_TOKEN_UPDATE';
+const ACTION_USER_ACCESS_TOKEN_CREATE = 'USER_ACCESS_TOKEN_CREATE';
+const ACTION_USER_ACCESS_TOKEN_DELETE = 'USER_ACCESS_TOKEN_DELETE';
 const ACTION_USER_EDITOR_SETTINGS_UPDATE = 'USER_EDITOR_SETTINGS_UPDATE';
 const ACTION_USER_EDITOR_SETTINGS_UPDATE = 'USER_EDITOR_SETTINGS_UPDATE';
 const ACTION_USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE = 'USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE';
 const ACTION_USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE = 'USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE';
 const ACTION_PAGE_VIEW = 'PAGE_VIEW';
 const ACTION_PAGE_VIEW = 'PAGE_VIEW';
@@ -208,7 +209,8 @@ export const SupportedAction = {
   ACTION_USER_LDAP_ACCOUNT_ASSOCIATE,
   ACTION_USER_LDAP_ACCOUNT_ASSOCIATE,
   ACTION_USER_LDAP_ACCOUNT_DISCONNECT,
   ACTION_USER_LDAP_ACCOUNT_DISCONNECT,
   ACTION_USER_PASSWORD_UPDATE,
   ACTION_USER_PASSWORD_UPDATE,
-  ACTION_USER_API_TOKEN_UPDATE,
+  ACTION_USER_ACCESS_TOKEN_CREATE,
+  ACTION_USER_ACCESS_TOKEN_DELETE,
   ACTION_USER_EDITOR_SETTINGS_UPDATE,
   ACTION_USER_EDITOR_SETTINGS_UPDATE,
   ACTION_USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE,
   ACTION_USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE,
   ACTION_PAGE_VIEW,
   ACTION_PAGE_VIEW,
@@ -405,7 +407,8 @@ export const MediumActionGroup = {
   ACTION_USER_LDAP_ACCOUNT_ASSOCIATE,
   ACTION_USER_LDAP_ACCOUNT_ASSOCIATE,
   ACTION_USER_LDAP_ACCOUNT_DISCONNECT,
   ACTION_USER_LDAP_ACCOUNT_DISCONNECT,
   ACTION_USER_PASSWORD_UPDATE,
   ACTION_USER_PASSWORD_UPDATE,
-  ACTION_USER_API_TOKEN_UPDATE,
+  ACTION_USER_ACCESS_TOKEN_CREATE,
+  ACTION_USER_ACCESS_TOKEN_DELETE,
   ACTION_USER_EDITOR_SETTINGS_UPDATE,
   ACTION_USER_EDITOR_SETTINGS_UPDATE,
   ACTION_USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE,
   ACTION_USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE,
   ACTION_PAGE_LIKE,
   ACTION_PAGE_LIKE,

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

@@ -17,6 +17,7 @@ import instanciatePageBulkExportJobCleanUpCronService, {
   pageBulkExportJobCleanUpCronService,
   pageBulkExportJobCleanUpCronService,
 } from '~/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron';
 } from '~/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron';
 import instanciatePageBulkExportJobCronService from '~/features/page-bulk-export/server/service/page-bulk-export-job-cron';
 import instanciatePageBulkExportJobCronService from '~/features/page-bulk-export/server/service/page-bulk-export-job-cron';
+import { startCron as startAccessTokenCron } from '~/server/service/access-token';
 import { getGrowiVersion } from '~/utils/growi-version';
 import { getGrowiVersion } from '~/utils/growi-version';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { projectRoot } from '~/utils/project-dir-utils';
 import { projectRoot } from '~/utils/project-dir-utils';
@@ -59,7 +60,7 @@ class Crowi {
 
 
   /**
   /**
    * For retrieving other packages
    * For retrieving other packages
-   * @type {(req: import('express').Request, res: import('express').Response, next: import('express').NextFunction) => Promise<void>}
+   * @type {import('~/server/middlewares/access-token-parser').AccessTokenParser}
    */
    */
   accessTokenParser;
   accessTokenParser;
 
 
@@ -362,6 +363,7 @@ Crowi.prototype.setupCron = function() {
   pageBulkExportJobCleanUpCronService.startCron();
   pageBulkExportJobCleanUpCronService.startCron();
 
 
   startOpenaiCronIfEnabled();
   startOpenaiCronIfEnabled();
+  startAccessTokenCron();
 };
 };
 
 
 Crowi.prototype.getSlack = function() {
 Crowi.prototype.getSlack = function() {

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

@@ -67,5 +67,6 @@ export const setupIndependentModels = async(): Promise<void> => {
     import('../models/user-group'),
     import('../models/user-group'),
     import('../models/user-registration-order'),
     import('../models/user-registration-order'),
     import('../models/user-ui-settings'),
     import('../models/user-ui-settings'),
+    import('../models/access-token'),
   ]);
   ]);
 };
 };

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

@@ -0,0 +1,213 @@
+import { faker } from '@faker-js/faker';
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
+import type { Response } from 'express';
+import { mock } from 'vitest-mock-extended';
+
+import { SCOPE } from '@growi/core/dist/interfaces';
+import type Crowi from '~/server/crowi';
+import type UserEvent from '~/server/events/user';
+import { AccessToken } from '~/server/models/access-token';
+
+import { parserForAccessToken } from './access-token';
+import type { AccessTokenParserReq } from './interfaces';
+
+vi.mock('@growi/core/dist/models/serializers', { spy: true });
+
+
+describe('access-token-parser middleware for access token with scopes', () => {
+
+  let User;
+
+  beforeAll(async() => {
+    const crowiMock = mock<Crowi>({
+      event: vi.fn().mockImplementation((eventName) => {
+        if (eventName === 'user') {
+          return mock<UserEvent>({
+            on: vi.fn(),
+          });
+        }
+      }),
+    });
+    const userModelFactory = (await import('../../models/user')).default;
+    User = userModelFactory(crowiMock);
+  });
+
+  it('should call next if no access token is provided', async() => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: undefined,
+    });
+    const resMock = mock<Response>();
+
+    await parserForAccessToken([])(reqMock, resMock);
+
+    expect(reqMock.user).toBeUndefined();
+  });
+
+  it('should not authenticate with no scopes', async() => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: undefined,
+    });
+    const resMock = mock<Response>();
+
+    expect(reqMock.user).toBeUndefined();
+
+    // prepare a user
+    const targetUser = await User.create({
+      name: faker.person.fullName(),
+      username: faker.string.uuid(),
+      password: faker.internet.password(),
+      lang: 'en_US',
+    });
+
+    // generate token with read:user:info scope
+    const { token } = await AccessToken.generateToken(
+      targetUser._id,
+      new Date(Date.now() + 1000 * 60 * 60 * 24),
+    );
+
+    // act
+    reqMock.query.access_token = token;
+    await parserForAccessToken([])(reqMock, resMock);
+
+    // assert
+    expect(reqMock.user).toBeUndefined();
+    expect(serializeUserSecurely).not.toHaveBeenCalled();
+  });
+
+  it('should authenticate with specific scope', async() => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: undefined,
+    });
+    const resMock = mock<Response>();
+
+    expect(reqMock.user).toBeUndefined();
+
+    // prepare a user
+    const targetUser = await User.create({
+      name: faker.person.fullName(),
+      username: faker.string.uuid(),
+      password: faker.internet.password(),
+      lang: 'en_US',
+    });
+
+    // generate token with read:user:info scope
+    const { token } = await AccessToken.generateToken(
+      targetUser._id,
+      new Date(Date.now() + 1000 * 60 * 60 * 24),
+      [SCOPE.READ.USER_SETTINGS.INFO],
+    );
+
+    // act
+    reqMock.query.access_token = token;
+    await parserForAccessToken([SCOPE.READ.USER_SETTINGS.INFO])(reqMock, resMock);
+
+    // assert
+    expect(reqMock.user).toBeDefined();
+    expect(reqMock.user?._id).toStrictEqual(targetUser._id);
+    expect(serializeUserSecurely).toHaveBeenCalledOnce();
+  });
+
+  it('should reject with insufficient scopes', async() => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: undefined,
+    });
+    const resMock = mock<Response>();
+
+    expect(reqMock.user).toBeUndefined();
+
+
+    // prepare a user
+    const targetUser = await User.create({
+      name: faker.person.fullName(),
+      username: faker.string.uuid(),
+      password: faker.internet.password(),
+      lang: 'en_US',
+    });
+
+    // generate token with read:user:info scope
+    const { token } = await AccessToken.generateToken(
+      targetUser._id,
+      new Date(Date.now() + 1000 * 60 * 60 * 24),
+      [SCOPE.READ.USER_SETTINGS.INFO],
+    );
+
+    // act - try to access with write:user:info scope
+    reqMock.query.access_token = token;
+    await parserForAccessToken([SCOPE.WRITE.USER_SETTINGS.INFO])(reqMock, resMock);
+
+    // // assert
+    expect(reqMock.user).toBeUndefined();
+    expect(serializeUserSecurely).not.toHaveBeenCalled();
+  });
+
+  it('should authenticate with write scope implying read scope', async() => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: undefined,
+    });
+    const resMock = mock<Response>();
+
+    expect(reqMock.user).toBeUndefined();
+
+    // prepare a user
+    const targetUser = await User.create({
+      name: faker.person.fullName(),
+      username: faker.string.uuid(),
+      password: faker.internet.password(),
+      lang: 'en_US',
+    });
+
+    // generate token with write:user:info scope
+    const { token } = await AccessToken.generateToken(
+      targetUser._id,
+      new Date(Date.now() + 1000 * 60 * 60 * 24),
+      [SCOPE.WRITE.USER_SETTINGS.INFO],
+    );
+
+    // act - try to access with read:user:info scope
+    reqMock.query.access_token = token;
+    await parserForAccessToken([SCOPE.READ.USER_SETTINGS.INFO])(reqMock, resMock);
+
+    // assert
+    expect(reqMock.user).toBeDefined();
+    expect(reqMock.user?._id).toStrictEqual(targetUser._id);
+    expect(serializeUserSecurely).toHaveBeenCalledOnce();
+  });
+
+  it('should authenticate with wildcard scope', async() => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: undefined,
+    });
+    const resMock = mock<Response>();
+
+    // prepare a user
+    const targetUser = await User.create({
+      name: faker.person.fullName(),
+      username: faker.string.uuid(),
+      password: faker.internet.password(),
+      lang: 'en_US',
+    });
+
+    // generate token with read:user:* scope
+    const { token } = await AccessToken.generateToken(
+      targetUser._id,
+      new Date(Date.now() + 1000 * 60 * 60 * 24),
+      [SCOPE.READ.USER_SETTINGS.ALL],
+    );
+
+    // act - try to access with read:user:info scope
+    reqMock.query.access_token = token;
+    await parserForAccessToken([SCOPE.READ.USER_SETTINGS.INFO, SCOPE.READ.USER_SETTINGS.API.ACCESS_TOKEN])(reqMock, resMock);
+
+    // assert
+    expect(reqMock.user).toBeDefined();
+    expect(reqMock.user?._id).toStrictEqual(targetUser._id);
+    expect(serializeUserSecurely).toHaveBeenCalledOnce();
+  });
+
+});

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

@@ -0,0 +1,53 @@
+import type { IUserHasId, Scope } from '@growi/core/dist/interfaces';
+import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
+import type { Response } from 'express';
+
+import { AccessToken } from '~/server/models/access-token';
+import loggerFactory from '~/utils/logger';
+
+import type { AccessTokenParserReq } from './interfaces';
+
+const logger = loggerFactory('growi:middleware:access-token-parser:access-token');
+
+export const parserForAccessToken = (scopes: Scope[]) => {
+  return async(req: AccessTokenParserReq, res: Response): Promise<void> => {
+
+    const accessToken = req.query.access_token ?? req.body.access_token;
+    if (accessToken == null || typeof accessToken !== 'string') {
+      return;
+    }
+    if (scopes == null || scopes.length === 0) {
+      logger.debug('scopes is empty');
+      return;
+    }
+
+    // check the access token is valid
+    const userId = await AccessToken.findUserIdByToken(accessToken, scopes);
+    if (userId == null) {
+      logger.debug('The access token is invalid');
+      return;
+    }
+
+    // check the user is valid
+    const { user: userByAccessToken }: {user: IUserHasId} = await userId.populate('user');
+    if (userByAccessToken == null) {
+      logger.debug('The access token\'s associated user is invalid');
+      return;
+    }
+
+    if (userByAccessToken.readOnly) {
+      logger.debug('The access token\'s associated user is read-only');
+      return;
+    }
+
+    // transforming attributes
+    req.user = serializeUserSecurely(userByAccessToken);
+    if (req.user == null) {
+      return;
+    }
+
+    logger.debug('Access token parsed.');
+    return;
+
+  };
+};

+ 10 - 22
apps/app/src/server/middlewares/access-token-parser/access-token-parser.integ.ts → apps/app/src/server/middlewares/access-token-parser/api-token.integ.ts

@@ -1,3 +1,4 @@
+
 import { faker } from '@faker-js/faker';
 import { faker } from '@faker-js/faker';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import type { Response } from 'express';
 import type { Response } from 'express';
@@ -6,11 +7,9 @@ import { mock } from 'vitest-mock-extended';
 import type Crowi from '~/server/crowi';
 import type Crowi from '~/server/crowi';
 import type UserEvent from '~/server/events/user';
 import type UserEvent from '~/server/events/user';
 
 
-
+import { parserForApiToken } from './api-token';
 import type { AccessTokenParserReq } from './interfaces';
 import type { AccessTokenParserReq } from './interfaces';
 
 
-import { accessTokenParser } from '.';
-
 
 
 vi.mock('@growi/core/dist/models/serializers', { spy: true });
 vi.mock('@growi/core/dist/models/serializers', { spy: true });
 
 
@@ -39,17 +38,15 @@ describe('access-token-parser middleware', () => {
       user: undefined,
       user: undefined,
     });
     });
     const resMock = mock<Response>();
     const resMock = mock<Response>();
-    const nextMock = vi.fn();
 
 
     expect(reqMock.user).toBeUndefined();
     expect(reqMock.user).toBeUndefined();
 
 
     // act
     // act
-    await accessTokenParser(reqMock, resMock, nextMock);
+    await parserForApiToken(reqMock, resMock);
 
 
     // assert
     // assert
     expect(reqMock.user).toBeUndefined();
     expect(reqMock.user).toBeUndefined();
     expect(serializeUserSecurely).not.toHaveBeenCalled();
     expect(serializeUserSecurely).not.toHaveBeenCalled();
-    expect(nextMock).toHaveBeenCalled();
   });
   });
 
 
   it('should call next if the given access token is invalid', async() => {
   it('should call next if the given access token is invalid', async() => {
@@ -58,21 +55,19 @@ describe('access-token-parser middleware', () => {
       user: undefined,
       user: undefined,
     });
     });
     const resMock = mock<Response>();
     const resMock = mock<Response>();
-    const nextMock = vi.fn();
 
 
     expect(reqMock.user).toBeUndefined();
     expect(reqMock.user).toBeUndefined();
 
 
     // act
     // act
     reqMock.query.access_token = 'invalidToken';
     reqMock.query.access_token = 'invalidToken';
-    await accessTokenParser(reqMock, resMock, nextMock);
+    await parserForApiToken(reqMock, resMock);
 
 
     // assert
     // assert
     expect(reqMock.user).toBeUndefined();
     expect(reqMock.user).toBeUndefined();
     expect(serializeUserSecurely).not.toHaveBeenCalled();
     expect(serializeUserSecurely).not.toHaveBeenCalled();
-    expect(nextMock).toHaveBeenCalled();
   });
   });
 
 
-  it('should set req.user with a valid access token in query', async() => {
+  it('should set req.user with a valid api token in query', async() => {
     // arrange
     // arrange
     const reqMock = mock<AccessTokenParserReq>({
     const reqMock = mock<AccessTokenParserReq>({
       user: undefined,
       user: undefined,
@@ -93,22 +88,20 @@ describe('access-token-parser middleware', () => {
 
 
     // act
     // act
     reqMock.query.access_token = targetUser.apiToken;
     reqMock.query.access_token = targetUser.apiToken;
-    await accessTokenParser(reqMock, resMock, nextMock);
+    await parserForApiToken(reqMock, resMock);
 
 
     // assert
     // assert
     expect(reqMock.user).toBeDefined();
     expect(reqMock.user).toBeDefined();
     expect(reqMock.user?._id).toStrictEqual(targetUser._id);
     expect(reqMock.user?._id).toStrictEqual(targetUser._id);
     expect(serializeUserSecurely).toHaveBeenCalledOnce();
     expect(serializeUserSecurely).toHaveBeenCalledOnce();
-    expect(nextMock).toHaveBeenCalled();
   });
   });
 
 
-  it('should set req.user with a valid access token in body', async() => {
+  it('should set req.user with a valid api token in body', async() => {
     // arrange
     // arrange
     const reqMock = mock<AccessTokenParserReq>({
     const reqMock = mock<AccessTokenParserReq>({
       user: undefined,
       user: undefined,
     });
     });
     const resMock = mock<Response>();
     const resMock = mock<Response>();
-    const nextMock = vi.fn();
 
 
     expect(reqMock.user).toBeUndefined();
     expect(reqMock.user).toBeUndefined();
 
 
@@ -123,13 +116,12 @@ describe('access-token-parser middleware', () => {
 
 
     // act
     // act
     reqMock.body.access_token = targetUser.apiToken;
     reqMock.body.access_token = targetUser.apiToken;
-    await accessTokenParser(reqMock, resMock, nextMock);
+    await parserForApiToken(reqMock, resMock);
 
 
     // assert
     // assert
     expect(reqMock.user).toBeDefined();
     expect(reqMock.user).toBeDefined();
     expect(reqMock.user?._id).toStrictEqual(targetUser._id);
     expect(reqMock.user?._id).toStrictEqual(targetUser._id);
     expect(serializeUserSecurely).toHaveBeenCalledOnce();
     expect(serializeUserSecurely).toHaveBeenCalledOnce();
-    expect(nextMock).toHaveBeenCalled();
   });
   });
 
 
   it('should set req.user with a valid Bearer token in Authorization header', async() => {
   it('should set req.user with a valid Bearer token in Authorization header', async() => {
@@ -141,7 +133,6 @@ describe('access-token-parser middleware', () => {
       },
       },
     });
     });
     const resMock = mock<Response>();
     const resMock = mock<Response>();
-    const nextMock = vi.fn();
 
 
     expect(reqMock.user).toBeUndefined();
     expect(reqMock.user).toBeUndefined();
 
 
@@ -156,13 +147,12 @@ describe('access-token-parser middleware', () => {
 
 
     // act
     // act
     reqMock.headers.authorization = `Bearer ${targetUser.apiToken}`;
     reqMock.headers.authorization = `Bearer ${targetUser.apiToken}`;
-    await accessTokenParser(reqMock, resMock, nextMock);
+    await parserForApiToken(reqMock, resMock);
 
 
     // assert
     // assert
     expect(reqMock.user).toBeDefined();
     expect(reqMock.user).toBeDefined();
     expect(reqMock.user?._id).toStrictEqual(targetUser._id);
     expect(reqMock.user?._id).toStrictEqual(targetUser._id);
     expect(serializeUserSecurely).toHaveBeenCalledOnce();
     expect(serializeUserSecurely).toHaveBeenCalledOnce();
-    expect(nextMock).toHaveBeenCalled();
   });
   });
 
 
   it('should ignore non-Bearer Authorization header', async() => {
   it('should ignore non-Bearer Authorization header', async() => {
@@ -174,7 +164,6 @@ describe('access-token-parser middleware', () => {
       },
       },
     });
     });
     const resMock = mock<Response>();
     const resMock = mock<Response>();
-    const nextMock = vi.fn();
 
 
     expect(reqMock.user).toBeUndefined();
     expect(reqMock.user).toBeUndefined();
 
 
@@ -183,12 +172,11 @@ describe('access-token-parser middleware', () => {
 
 
     // act
     // act
     reqMock.headers.authorization = `Basic ${randomString}`; // Basic auth header with random string
     reqMock.headers.authorization = `Basic ${randomString}`; // Basic auth header with random string
-    await accessTokenParser(reqMock, resMock, nextMock);
+    await parserForApiToken(reqMock, resMock);
 
 
     // assert
     // assert
     expect(reqMock.user).toBeUndefined();
     expect(reqMock.user).toBeUndefined();
     expect(serializeUserSecurely).not.toHaveBeenCalled();
     expect(serializeUserSecurely).not.toHaveBeenCalled();
-    expect(nextMock).toHaveBeenCalled();
   });
   });
 
 
 });
 });

+ 13 - 14
apps/app/src/server/middlewares/access-token-parser/access-token-parser.ts → apps/app/src/server/middlewares/access-token-parser/api-token.ts

@@ -8,8 +8,7 @@ import loggerFactory from '~/utils/logger';
 
 
 import type { AccessTokenParserReq } from './interfaces';
 import type { AccessTokenParserReq } from './interfaces';
 
 
-const logger = loggerFactory('growi:middleware:access-token-parser');
-
+const logger = loggerFactory('growi:middleware:access-token-parser:api-token');
 
 
 const extractBearerToken = (authHeader: string | undefined): string | null => {
 const extractBearerToken = (authHeader: string | undefined): string | null => {
   if (authHeader == null) {
   if (authHeader == null) {
@@ -23,7 +22,8 @@ const extractBearerToken = (authHeader: string | undefined): string | null => {
   return authHeader.substring(7); // Remove 'Bearer ' prefix
   return authHeader.substring(7); // Remove 'Bearer ' prefix
 };
 };
 
 
-export const accessTokenParser = async(req: AccessTokenParserReq, res: Response, next: NextFunction): Promise<void> => {
+
+export const parserForApiToken = async(req: AccessTokenParserReq, res: Response): Promise<void> => {
   // Extract token from Authorization header first
   // Extract token from Authorization header first
   const bearerToken = extractBearerToken(req.headers.authorization);
   const bearerToken = extractBearerToken(req.headers.authorization);
 
 
@@ -31,24 +31,23 @@ export const accessTokenParser = async(req: AccessTokenParserReq, res: Response,
   const accessToken = bearerToken ?? req.query.access_token ?? req.body.access_token;
   const accessToken = bearerToken ?? req.query.access_token ?? req.body.access_token;
 
 
   if (accessToken == null || typeof accessToken !== 'string') {
   if (accessToken == null || typeof accessToken !== 'string') {
-    return next();
+    return;
   }
   }
 
 
-  const User = mongoose.model<HydratedDocument<IUser>, { findUserByApiToken }>('User');
-
   logger.debug('accessToken is', accessToken);
   logger.debug('accessToken is', accessToken);
 
 
-  const user: IUserHasId = await User.findUserByApiToken(accessToken);
+  const User = mongoose.model<HydratedDocument<IUser>, { findUserByApiToken }>('User');
+  const userByApiToken: IUserHasId = await User.findUserByApiToken(accessToken);
 
 
-  if (user == null) {
-    logger.debug('The access token is invalid');
-    return next();
+  if (userByApiToken == null) {
+    return;
   }
   }
 
 
-  // transforming attributes
-  req.user = serializeUserSecurely(user);
+  req.user = serializeUserSecurely(userByApiToken);
+  if (req.user == null) {
+    return;
+  }
 
 
   logger.debug('Access token parsed.');
   logger.debug('Access token parsed.');
-
-  return next();
+  return;
 };
 };

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

@@ -1 +1,31 @@
-export * from './access-token-parser';
+import type { Scope } from '@growi/core/dist/interfaces';
+import type { NextFunction, Response } from 'express';
+
+import loggerFactory from '~/utils/logger';
+
+import { parserForAccessToken } from './access-token';
+import { parserForApiToken } from './api-token';
+import type { AccessTokenParserReq } from './interfaces';
+
+const logger = loggerFactory('growi:middleware:access-token-parser');
+
+export type AccessTokenParser = (scopes?: Scope[], opts?: {acceptLegacy: boolean})
+  => (req: AccessTokenParserReq, res: Response, next: NextFunction) => Promise<void>
+
+export const accessTokenParser: AccessTokenParser = (scopes, opts) => {
+  return async(req, res, next): Promise<void> => {
+    // TODO: comply HTTP header of RFC6750 / Authorization: Bearer
+    if (scopes == null || scopes.length === 0) {
+      logger.warn('scopes is empty');
+      return next();
+    }
+
+    await parserForAccessToken(scopes)(req, res);
+
+    if (opts?.acceptLegacy) {
+      await parserForApiToken(req, res);
+    }
+
+    return next();
+  };
+};

+ 127 - 0
apps/app/src/server/models/access-token.ts

@@ -0,0 +1,127 @@
+import crypto from 'crypto';
+
+import type { Ref, IUserHasId, Scope } from '@growi/core/dist/interfaces';
+import type {
+  Document, Model, Types, HydratedDocument,
+} from 'mongoose';
+import { Schema } from 'mongoose';
+import mongoosePaginate from 'mongoose-paginate-v2';
+import uniqueValidator from 'mongoose-unique-validator';
+
+import loggerFactory from '~/utils/logger';
+
+import { getOrCreateModel } from '../util/mongoose-utils';
+import { extractScopes } from '../util/scope-utils';
+
+const logger = loggerFactory('growi:models:access-token');
+
+const generateTokenHash = (token: string) => crypto.createHash('sha256').update(token).digest('hex');
+
+type GenerateTokenResult = {
+  token: string,
+  _id: Types.ObjectId,
+  expiredAt: Date,
+  scopes?: Scope[],
+  description?: string,
+}
+
+export type IAccessToken = {
+  user: Ref<IUserHasId>,
+  tokenHash: string,
+  expiredAt: Date,
+  scopes?: Scope[],
+  description?: string,
+}
+
+export interface IAccessTokenDocument extends IAccessToken, Document {
+  isExpired: () => boolean
+}
+
+export interface IAccessTokenModel extends Model<IAccessTokenDocument> {
+  generateToken: (userId: Types.ObjectId | string, expiredAt: Date, scopes?: Scope[], description?: string,) => Promise<GenerateTokenResult>
+  deleteToken: (token: string) => Promise<void>
+  deleteTokenById: (tokenId: Types.ObjectId | string) => Promise<void>
+  deleteAllTokensByUserId: (userId: Types.ObjectId | string) => Promise<void>
+  deleteExpiredToken: () => Promise<void>
+  findUserIdByToken: (token: string, requiredScopes: Scope[]) => Promise<HydratedDocument<IAccessTokenDocument> | null>
+  findTokenByUserId: (userId: Types.ObjectId | string) => Promise<HydratedDocument<IAccessTokenDocument>[] | null>
+  validateTokenScopes: (token: string, requiredScopes: Scope[]) => Promise<boolean>
+}
+
+const accessTokenSchema = new Schema<IAccessTokenDocument, IAccessTokenModel>({
+  user: {
+    type: Schema.Types.ObjectId, ref: 'User', required: true,
+  },
+  tokenHash: { type: String, required: true, unique: true },
+  expiredAt: { type: Date, required: true, index: true },
+  scopes: [{ type: String, default: '' }],
+  description: { type: String, default: '' },
+});
+
+accessTokenSchema.plugin(mongoosePaginate);
+accessTokenSchema.plugin(uniqueValidator);
+
+accessTokenSchema.statics.generateToken = async function(userId: Types.ObjectId | string, expiredAt: Date, scopes?: Scope[], description?: string) {
+
+  const extractedScopes = extractScopes(scopes ?? []);
+  const token = crypto.randomBytes(32).toString('hex');
+  const tokenHash = generateTokenHash(token);
+
+  try {
+    const { _id } = await this.create({
+      user: userId, tokenHash, expiredAt, scopes: extractedScopes, description,
+    });
+
+    logger.debug('Token generated');
+    return {
+      token, _id, expiredAt, scopes: extractedScopes, description,
+    };
+  }
+  catch (err) {
+    logger.debug('Failed to generate token');
+    throw err;
+  }
+};
+
+accessTokenSchema.statics.deleteToken = async function(token: string) {
+  const tokenHash = generateTokenHash(token);
+  await this.deleteOne({ tokenHash });
+};
+
+accessTokenSchema.statics.deleteTokenById = async function(tokenId: Types.ObjectId | string) {
+  await this.deleteOne({ _id: tokenId });
+};
+
+accessTokenSchema.statics.deleteAllTokensByUserId = async function(userId: Types.ObjectId | string) {
+  await this.deleteMany({ user: userId });
+};
+
+accessTokenSchema.statics.deleteExpiredToken = async function() {
+  const now = new Date();
+  await this.deleteMany({ expiredAt: { $lt: now } });
+};
+
+accessTokenSchema.statics.findUserIdByToken = async function(token: string, requiredScopes: Scope[]) {
+  const tokenHash = generateTokenHash(token);
+  const now = new Date();
+  if (requiredScopes.length === 0) {
+    return;
+  }
+  const extractedScopes = extractScopes(requiredScopes);
+  return this.findOne({ tokenHash, expiredAt: { $gte: now }, scopes: { $all: extractedScopes } }).select('user');
+};
+
+accessTokenSchema.statics.findTokenByUserId = async function(userId: Types.ObjectId | string) {
+  const now = new Date();
+  return this.find({ user: userId, expiredAt: { $gte: now } }).select('_id expiredAt scopes description');
+};
+
+accessTokenSchema.statics.validateTokenScopes = async function(token: string, requiredScopes: Scope[]) {
+  return this.findUserIdByToken(token, requiredScopes) != null;
+};
+
+accessTokenSchema.methods.isExpired = function() {
+  return this.expiredAt < new Date();
+};
+
+export const AccessToken = getOrCreateModel<IAccessTokenDocument, IAccessTokenModel>('AccessToken', accessTokenSchema);

+ 5 - 5
apps/app/src/server/models/user.js

@@ -119,11 +119,6 @@ const factory = (crowi) => {
     }
     }
   }
   }
 
 
-  function generateRandomEmail() {
-    const randomstr = generateRandomTempPassword();
-    return `change-it-${randomstr}@example.com`;
-  }
-
   function generateRandomTempPassword() {
   function generateRandomTempPassword() {
     const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!=-_';
     const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!=-_';
     let password = '';
     let password = '';
@@ -137,6 +132,11 @@ const factory = (crowi) => {
     return password;
     return password;
   }
   }
 
 
+  function generateRandomEmail() {
+    const randomstr = generateRandomTempPassword();
+    return `change-it-${randomstr}@example.com`;
+  }
+
   function generatePassword(password) {
   function generatePassword(password) {
     validateCrowi();
     validateCrowi();
 
 

+ 81 - 78
apps/app/src/server/routes/apiv3/activity.ts

@@ -5,6 +5,7 @@ import express from 'express';
 import { query } from 'express-validator';
 import { query } from 'express-validator';
 
 
 import type { IActivity, ISearchFilter } from '~/interfaces/activity';
 import type { IActivity, ISearchFilter } from '~/interfaces/activity';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import Activity from '~/server/models/activity';
 import Activity from '~/server/models/activity';
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
@@ -208,98 +209,100 @@ module.exports = (crowi: Crowi): Router => {
    *             schema:
    *             schema:
    *               $ref: '#/components/schemas/ActivityResponse'
    *               $ref: '#/components/schemas/ActivityResponse'
    */
    */
-  router.get('/', accessTokenParser, loginRequiredStrictly, adminRequired, validator.list, apiV3FormValidator, async(req: Request, res: ApiV3Response) => {
-    const auditLogEnabled = configManager.getConfig('app:auditLogEnabled');
-    if (!auditLogEnabled) {
-      const msg = 'AuditLog is not enabled';
-      logger.error(msg);
-      return res.apiv3Err(msg, 405);
-    }
+  router.get('/',
+    accessTokenParser([SCOPE.READ.ADMIN.AUDIT_LOG], { acceptLegacy: true }),
+    loginRequiredStrictly, adminRequired, validator.list, apiV3FormValidator, async(req: Request, res: ApiV3Response) => {
+      const auditLogEnabled = configManager.getConfig('app:auditLogEnabled');
+      if (!auditLogEnabled) {
+        const msg = 'AuditLog is not enabled';
+        logger.error(msg);
+        return res.apiv3Err(msg, 405);
+      }
 
 
-    const limit = req.query.limit || configManager.getConfig('customize:showPageLimitationS');
-    const offset = req.query.offset || 1;
+      const limit = req.query.limit || configManager.getConfig('customize:showPageLimitationS');
+      const offset = req.query.offset || 1;
 
 
-    const query = {};
+      const query = {};
 
 
-    try {
-      const parsedSearchFilter = JSON.parse(req.query.searchFilter as string) as ISearchFilter;
+      try {
+        const parsedSearchFilter = JSON.parse(req.query.searchFilter as string) as ISearchFilter;
 
 
-      // add username to query
-      const canContainUsernameFilterToQuery = (
-        parsedSearchFilter.usernames != null
+        // add username to query
+        const canContainUsernameFilterToQuery = (
+          parsedSearchFilter.usernames != null
         && parsedSearchFilter.usernames.length > 0
         && parsedSearchFilter.usernames.length > 0
         && parsedSearchFilter.usernames.every(u => typeof u === 'string')
         && parsedSearchFilter.usernames.every(u => typeof u === 'string')
-      );
-      if (canContainUsernameFilterToQuery) {
-        Object.assign(query, { 'snapshot.username': parsedSearchFilter.usernames });
-      }
+        );
+        if (canContainUsernameFilterToQuery) {
+          Object.assign(query, { 'snapshot.username': parsedSearchFilter.usernames });
+        }
 
 
-      // add action to query
-      if (parsedSearchFilter.actions != null) {
-        const availableActions = crowi.activityService.getAvailableActions(false);
-        const searchableActions = parsedSearchFilter.actions.filter(action => availableActions.includes(action));
-        Object.assign(query, { action: searchableActions });
-      }
+        // add action to query
+        if (parsedSearchFilter.actions != null) {
+          const availableActions = crowi.activityService.getAvailableActions(false);
+          const searchableActions = parsedSearchFilter.actions.filter(action => availableActions.includes(action));
+          Object.assign(query, { action: searchableActions });
+        }
 
 
-      // add date to query
-      const startDate = parseISO(parsedSearchFilter?.dates?.startDate || '');
-      const endDate = parseISO(parsedSearchFilter?.dates?.endDate || '');
-      if (isValid(startDate) && isValid(endDate)) {
-        Object.assign(query, {
-          createdAt: {
-            $gte: startDate,
-            // + 23 hours 59 minutes
-            $lt: addMinutes(endDate, 1439),
-          },
-        });
+        // add date to query
+        const startDate = parseISO(parsedSearchFilter?.dates?.startDate || '');
+        const endDate = parseISO(parsedSearchFilter?.dates?.endDate || '');
+        if (isValid(startDate) && isValid(endDate)) {
+          Object.assign(query, {
+            createdAt: {
+              $gte: startDate,
+              // + 23 hours 59 minutes
+              $lt: addMinutes(endDate, 1439),
+            },
+          });
+        }
+        else if (isValid(startDate) && !isValid(endDate)) {
+          Object.assign(query, {
+            createdAt: {
+              $gte: startDate,
+              // + 23 hours 59 minutes
+              $lt: addMinutes(startDate, 1439),
+            },
+          });
+        }
       }
       }
-      else if (isValid(startDate) && !isValid(endDate)) {
-        Object.assign(query, {
-          createdAt: {
-            $gte: startDate,
-            // + 23 hours 59 minutes
-            $lt: addMinutes(startDate, 1439),
-          },
-        });
+      catch (err) {
+        logger.error('Invalid value', err);
+        return res.apiv3Err(err, 400);
       }
       }
-    }
-    catch (err) {
-      logger.error('Invalid value', err);
-      return res.apiv3Err(err, 400);
-    }
 
 
-    try {
-      const paginateResult = await Activity.paginate(
-        query,
-        {
-          lean: true,
-          limit,
-          offset,
-          sort: { createdAt: -1 },
-          populate: 'user',
-        },
-      );
+      try {
+        const paginateResult = await Activity.paginate(
+          query,
+          {
+            lean: true,
+            limit,
+            offset,
+            sort: { createdAt: -1 },
+            populate: 'user',
+          },
+        );
 
 
-      const serializedDocs = paginateResult.docs.map((doc: IActivity) => {
-        const { user, ...rest } = doc;
-        return {
-          user: serializeUserSecurely(user),
-          ...rest,
-        };
-      });
+        const serializedDocs = paginateResult.docs.map((doc: IActivity) => {
+          const { user, ...rest } = doc;
+          return {
+            user: serializeUserSecurely(user),
+            ...rest,
+          };
+        });
 
 
-      const serializedPaginationResult = {
-        ...paginateResult,
-        docs: serializedDocs,
-      };
+        const serializedPaginationResult = {
+          ...paginateResult,
+          docs: serializedDocs,
+        };
 
 
-      return res.apiv3({ serializedPaginationResult });
-    }
-    catch (err) {
-      logger.error('Failed to get paginated activity', err);
-      return res.apiv3Err(err, 500);
-    }
-  });
+        return res.apiv3({ serializedPaginationResult });
+      }
+      catch (err) {
+        logger.error('Failed to get paginated activity', err);
+        return res.apiv3Err(err, 500);
+      }
+    });
 
 
   return router;
   return router;
 };
 };

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

@@ -1,3 +1,5 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
 import { getGrowiVersion } from '~/utils/growi-version';
 import { getGrowiVersion } from '~/utils/growi-version';
 
 
@@ -81,7 +83,7 @@ module.exports = (crowi) => {
    *                    adminHomeParams:
    *                    adminHomeParams:
    *                      $ref: "#/components/schemas/SystemInformationParams"
    *                      $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 { getRuntimeVersions } = await import('~/server/util/runtime-versions');
     const runtimeVersions = await getRuntimeVersions();
     const runtimeVersions = await getRuntimeVersions();
 
 

+ 269 - 257
apps/app/src/server/routes/apiv3/app-settings.js

@@ -1,5 +1,5 @@
 import {
 import {
-  ConfigSource, toNonBlankString, toNonBlankStringOrUndefined,
+  ConfigSource, toNonBlankString, toNonBlankStringOrUndefined, SCOPE,
 } from '@growi/core/dist/interfaces';
 } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { body } from 'express-validator';
 import { body } from 'express-validator';
@@ -418,7 +418,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      $ref: '#/components/schemas/AppSettingParams'
    *                      $ref: '#/components/schemas/AppSettingParams'
    */
    */
-  router.get('/', accessTokenParser, loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.APP], { acceptLegacy: true }), loginRequiredStrictly, adminRequired, async(req, res) => {
     const appSettingsParams = {
     const appSettingsParams = {
       title: configManager.getConfig('app:title'),
       title: configManager.getConfig('app:title'),
       confidential: configManager.getConfig('app:confidential'),
       confidential: configManager.getConfig('app:confidential'),
@@ -517,37 +517,39 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      $ref: '#/components/schemas/AppSettingPutParams'
    *                      $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
    * @swagger
@@ -581,36 +583,37 @@ module.exports = (crowi) => {
    *                          description: Site URL. e.g. https://example.com, https://example.com:3000
    *                          description: Site URL. e.g. https://example.com, https://example.com:3000
    *                          example: 'http://localhost:3000'
    *                          example: 'http://localhost:3000'
    */
    */
-  router.put('/site-url-setting', loginRequiredStrictly, adminRequired, addActivity, validator.siteUrlSetting, apiV3FormValidator, async(req, res) => {
-
-    const useOnlyEnvVars = configManager.getConfig('env:useOnlyEnvVars:app:siteUrl');
-
-    if (useOnlyEnvVars) {
-      const msg = 'Updating the Site URL is prohibited on this system.';
-      return res.apiv3Err(new ErrorV3(msg, 'update-siteUrlSetting-prohibited'));
-    }
+  router.put('/site-url-setting', accessTokenParser([SCOPE.WRITE.ADMIN.APP]), loginRequiredStrictly, adminRequired, addActivity,
+    validator.siteUrlSetting, apiV3FormValidator,
+    async(req, res) => {
+      const useOnlyEnvVars = configManager.getConfig('env:useOnlyEnvVars:app:siteUrl');
 
 
-    const requestSiteUrlSettingParams = {
-      'app:siteUrl': pathUtils.removeTrailingSlash(req.body.siteUrl),
-    };
+      if (useOnlyEnvVars) {
+        const msg = 'Updating the Site URL is prohibited on this system.';
+        return res.apiv3Err(new ErrorV3(msg, 'update-siteUrlSetting-prohibited'));
+      }
 
 
-    try {
-      await configManager.updateConfigs(requestSiteUrlSettingParams);
-      const siteUrlSettingParams = {
-        siteUrl: configManager.getConfig('app:siteUrl'),
+      const requestSiteUrlSettingParams = {
+        'app:siteUrl': pathUtils.removeTrailingSlash(req.body.siteUrl),
       };
       };
 
 
-      const parameters = { action: SupportedAction.ACTION_ADMIN_SITE_URL_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-      return res.apiv3({ siteUrlSettingParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating site url setting';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-siteUrlSetting-failed'));
-    }
+      try {
+        await configManager.updateConfigs(requestSiteUrlSettingParams);
+        const siteUrlSettingParams = {
+          siteUrl: configManager.getConfig('app:siteUrl'),
+        };
 
 
-  });
+        const parameters = { action: SupportedAction.ACTION_ADMIN_SITE_URL_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+        return res.apiv3({ siteUrlSettingParams });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating site url setting';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-siteUrlSetting-failed'));
+      }
+
+    });
 
 
   /**
   /**
    * send mail (Promise wrapper)
    * send mail (Promise wrapper)
@@ -727,28 +730,30 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      $ref: '#/components/schemas/SmtpSettingResponseParams'
    *                      $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
    * @swagger
@@ -769,7 +774,7 @@ module.exports = (crowi) => {
    *                  type: object
    *                  type: object
    *                  description: Empty 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 });
     const { t } = await getTranslation({ lang: req.user.lang });
 
 
     try {
     try {
@@ -810,32 +815,34 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/SesSettingResponseParams'
    *                  $ref: '#/components/schemas/SesSettingResponseParams'
    */
    */
-  router.put('/ses-setting', loginRequiredStrictly, adminRequired, addActivity, validator.sesSetting, apiV3FormValidator, async(req, res) => {
-    const { mailService } = crowi;
+  router.put('/ses-setting', accessTokenParser([SCOPE.WRITE.ADMIN.APP]), loginRequiredStrictly, adminRequired, addActivity,
+    validator.sesSetting, apiV3FormValidator,
+    async(req, res) => {
+      const { mailService } = crowi;
 
 
-    const requestSesSettingParams = {
-      'mail:from': req.body.fromAddress,
-      'mail:transmissionMethod': req.body.transmissionMethod,
-      'mail:sesAccessKeyId': req.body.sesAccessKeyId,
-      'mail:sesSecretAccessKey': req.body.sesSecretAccessKey,
-    };
+      const requestSesSettingParams = {
+        'mail:from': req.body.fromAddress,
+        'mail:transmissionMethod': req.body.transmissionMethod,
+        'mail:sesAccessKeyId': req.body.sesAccessKeyId,
+        'mail:sesSecretAccessKey': req.body.sesSecretAccessKey,
+      };
 
 
-    let mailSettingParams;
-    try {
-      mailSettingParams = await updateMailSettinConfig(requestSesSettingParams);
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating ses setting';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-ses-setting-failed'));
-    }
+      let mailSettingParams;
+      try {
+        mailSettingParams = await updateMailSettinConfig(requestSesSettingParams);
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating ses setting';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-ses-setting-failed'));
+      }
 
 
-    await mailService.initialize();
-    mailService.publishUpdatedMessage();
-    const parameters = { action: SupportedAction.ACTION_ADMIN_MAIL_SES_UPDATE };
-    activityEvent.emit('update', res.locals.activity._id, parameters);
-    return res.apiv3({ mailSettingParams });
-  });
+      await mailService.initialize();
+      mailService.publishUpdatedMessage();
+      const parameters = { action: SupportedAction.ACTION_ADMIN_MAIL_SES_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+      return res.apiv3({ mailSettingParams });
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -866,147 +873,150 @@ module.exports = (crowi) => {
    *                      $ref: '#/components/schemas/FileUploadSettingParams'
    *                      $ref: '#/components/schemas/FileUploadSettingParams'
    */
    */
   //  eslint-disable-next-line max-len
   //  eslint-disable-next-line max-len
-  router.put('/file-upload-setting', loginRequiredStrictly, adminRequired, addActivity, validator.fileUploadSetting, apiV3FormValidator, async(req, res) => {
-    const { fileUploadType } = req.body;
+  router.put('/file-upload-setting', accessTokenParser([SCOPE.WRITE.ADMIN.APP]),
+    loginRequiredStrictly, adminRequired, addActivity, validator.fileUploadSetting, apiV3FormValidator, async(req, res) => {
+      const { fileUploadType } = req.body;
 
 
-    if (fileUploadType === 'local' || fileUploadType === 'gridfs') {
-      try {
-        await configManager.updateConfigs({
-          'app:fileUploadType': fileUploadType,
-        }, { skipPubsub: true });
-      }
-      catch (err) {
-        const msg = `Error occurred in updating ${fileUploadType} settings: ${err.message}`;
-        logger.error('Error', err);
-        return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
+      if (fileUploadType === 'local' || fileUploadType === 'gridfs') {
+        try {
+          await configManager.updateConfigs({
+            'app:fileUploadType': fileUploadType,
+          }, { skipPubsub: true });
+        }
+        catch (err) {
+          const msg = `Error occurred in updating ${fileUploadType} settings: ${err.message}`;
+          logger.error('Error', err);
+          return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
+        }
       }
       }
-    }
 
 
-    if (fileUploadType === 'aws') {
-      try {
+      if (fileUploadType === 'aws') {
         try {
         try {
-          toNonBlankString(req.body.s3Bucket);
+          try {
+            toNonBlankString(req.body.s3Bucket);
+          }
+          catch (err) {
+            throw new Error('S3 Bucket name is required');
+          }
+          try {
+            toNonBlankString(req.body.s3Region);
+          }
+          catch (err) {
+            throw new Error('S3 Region is required');
+          }
+          await configManager.updateConfigs({
+            'app:fileUploadType': fileUploadType,
+            'aws:s3Region': toNonBlankString(req.body.s3Region),
+            'aws:s3Bucket': toNonBlankString(req.body.s3Bucket),
+            'aws:referenceFileWithRelayMode': req.body.s3ReferenceFileWithRelayMode,
+          },
+          { skipPubsub: true });
+          await configManager.updateConfigs({
+            'aws:s3CustomEndpoint': toNonBlankStringOrUndefined(req.body.s3CustomEndpoint),
+            'aws:s3AccessKeyId': toNonBlankStringOrUndefined(req.body.s3AccessKeyId),
+            'aws:s3SecretAccessKey': toNonBlankStringOrUndefined(req.body.s3SecretAccessKey),
+          },
+          {
+            skipPubsub: true,
+            removeIfUndefined: true,
+          });
         }
         }
         catch (err) {
         catch (err) {
-          throw new Error('S3 Bucket name is required');
+          const msg = `Error occurred in updating AWS S3 settings: ${err.message}`;
+          logger.error('Error', err);
+          return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
         }
         }
+      }
+
+      if (fileUploadType === 'gcs') {
         try {
         try {
-          toNonBlankString(req.body.s3Region);
+          await configManager.updateConfigs({
+            'app:fileUploadType': fileUploadType,
+            'gcs:referenceFileWithRelayMode': req.body.gcsReferenceFileWithRelayMode,
+          },
+          { skipPubsub: true });
+          await configManager.updateConfigs({
+            'gcs:apiKeyJsonPath': toNonBlankStringOrUndefined(req.body.gcsApiKeyJsonPath),
+            'gcs:bucket': toNonBlankStringOrUndefined(req.body.gcsBucket),
+            'gcs:uploadNamespace': toNonBlankStringOrUndefined(req.body.gcsUploadNamespace),
+          },
+          { skipPubsub: true, removeIfUndefined: true });
         }
         }
         catch (err) {
         catch (err) {
-          throw new Error('S3 Region is required');
+          const msg = `Error occurred in updating GCS settings: ${err.message}`;
+          logger.error('Error', err);
+          return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
         }
         }
-        await configManager.updateConfigs({
-          'app:fileUploadType': fileUploadType,
-          'aws:s3Region': toNonBlankString(req.body.s3Region),
-          'aws:s3Bucket': toNonBlankString(req.body.s3Bucket),
-          'aws:referenceFileWithRelayMode': req.body.s3ReferenceFileWithRelayMode,
-        },
-        { skipPubsub: true });
-        await configManager.updateConfigs({
-          'aws:s3CustomEndpoint': toNonBlankStringOrUndefined(req.body.s3CustomEndpoint),
-          'aws:s3AccessKeyId': toNonBlankStringOrUndefined(req.body.s3AccessKeyId),
-          'aws:s3SecretAccessKey': toNonBlankStringOrUndefined(req.body.s3SecretAccessKey),
-        },
-        {
-          skipPubsub: true,
-          removeIfUndefined: true,
-        });
       }
       }
-      catch (err) {
-        const msg = `Error occurred in updating AWS S3 settings: ${err.message}`;
-        logger.error('Error', err);
-        return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
-      }
-    }
 
 
-    if (fileUploadType === 'gcs') {
-      try {
-        await configManager.updateConfigs({
-          'app:fileUploadType': fileUploadType,
-          'gcs:referenceFileWithRelayMode': req.body.gcsReferenceFileWithRelayMode,
-        },
-        { skipPubsub: true });
-        await configManager.updateConfigs({
-          'gcs:apiKeyJsonPath': toNonBlankStringOrUndefined(req.body.gcsApiKeyJsonPath),
-          'gcs:bucket': toNonBlankStringOrUndefined(req.body.gcsBucket),
-          'gcs:uploadNamespace': toNonBlankStringOrUndefined(req.body.gcsUploadNamespace),
-        },
-        { skipPubsub: true, removeIfUndefined: true });
-      }
-      catch (err) {
-        const msg = `Error occurred in updating GCS settings: ${err.message}`;
-        logger.error('Error', err);
-        return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
+      if (fileUploadType === 'azure') {
+        try {
+          await configManager.updateConfigs({
+            'app:fileUploadType': fileUploadType,
+            'azure:referenceFileWithRelayMode': req.body.azureReferenceFileWithRelayMode,
+          },
+          { skipPubsub: true });
+          await configManager.updateConfigs({
+            'azure:tenantId': toNonBlankStringOrUndefined(req.body.azureTenantId),
+            'azure:clientId': toNonBlankStringOrUndefined(req.body.azureClientId),
+            'azure:clientSecret': toNonBlankStringOrUndefined(req.body.azureClientSecret),
+            'azure:storageAccountName': toNonBlankStringOrUndefined(req.body.azureStorageAccountName),
+            'azure:storageContainerName': toNonBlankStringOrUndefined(req.body.azureStorageContainerName),
+          }, { skipPubsub: true, removeIfUndefined: true });
+        }
+        catch (err) {
+          const msg = `Error occurred in updating Azure settings: ${err.message}`;
+          logger.error('Error', err);
+          return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
+        }
       }
       }
-    }
 
 
-    if (fileUploadType === 'azure') {
       try {
       try {
-        await configManager.updateConfigs({
-          'app:fileUploadType': fileUploadType,
-          'azure:referenceFileWithRelayMode': req.body.azureReferenceFileWithRelayMode,
-        },
-        { skipPubsub: true });
-        await configManager.updateConfigs({
-          'azure:tenantId': toNonBlankStringOrUndefined(req.body.azureTenantId),
-          'azure:clientId': toNonBlankStringOrUndefined(req.body.azureClientId),
-          'azure:clientSecret': toNonBlankStringOrUndefined(req.body.azureClientSecret),
-          'azure:storageAccountName': toNonBlankStringOrUndefined(req.body.azureStorageAccountName),
-          'azure:storageContainerName': toNonBlankStringOrUndefined(req.body.azureStorageContainerName),
-        }, { skipPubsub: true, removeIfUndefined: true });
-      }
-      catch (err) {
-        const msg = `Error occurred in updating Azure settings: ${err.message}`;
-        logger.error('Error', err);
-        return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
-      }
-    }
+        await crowi.setUpFileUpload(true);
+        crowi.fileUploaderSwitchService.publishUpdatedMessage();
 
 
-    try {
-      await crowi.setUpFileUpload(true);
-      crowi.fileUploaderSwitchService.publishUpdatedMessage();
+        const responseParams = {
+          fileUploadType: configManager.getConfig('app:fileUploadType'),
+        };
 
 
-      const responseParams = {
-        fileUploadType: configManager.getConfig('app:fileUploadType'),
-      };
+        if (fileUploadType === 'gcs') {
+          responseParams.gcsApiKeyJsonPath = configManager.getConfig('gcs:apiKeyJsonPath');
+          responseParams.gcsBucket = configManager.getConfig('gcs:bucket');
+          responseParams.gcsUploadNamespace = configManager.getConfig('gcs:uploadNamespace');
+          responseParams.gcsReferenceFileWithRelayMode = configManager.getConfig('gcs:referenceFileWithRelayMode ');
+        }
 
 
-      if (fileUploadType === 'gcs') {
-        responseParams.gcsApiKeyJsonPath = configManager.getConfig('gcs:apiKeyJsonPath');
-        responseParams.gcsBucket = configManager.getConfig('gcs:bucket');
-        responseParams.gcsUploadNamespace = configManager.getConfig('gcs:uploadNamespace');
-        responseParams.gcsReferenceFileWithRelayMode = configManager.getConfig('gcs:referenceFileWithRelayMode ');
-      }
+        if (fileUploadType === 'aws') {
+          responseParams.s3Region = configManager.getConfig('aws:s3Region');
+          responseParams.s3CustomEndpoint = configManager.getConfig('aws:s3CustomEndpoint');
+          responseParams.s3Bucket = configManager.getConfig('aws:s3Bucket');
+          responseParams.s3AccessKeyId = configManager.getConfig('aws:s3AccessKeyId');
+          responseParams.s3ReferenceFileWithRelayMode = configManager.getConfig('aws:referenceFileWithRelayMode');
+        }
 
 
-      if (fileUploadType === 'aws') {
-        responseParams.s3Region = configManager.getConfig('aws:s3Region');
-        responseParams.s3CustomEndpoint = configManager.getConfig('aws:s3CustomEndpoint');
-        responseParams.s3Bucket = configManager.getConfig('aws:s3Bucket');
-        responseParams.s3AccessKeyId = configManager.getConfig('aws:s3AccessKeyId');
-        responseParams.s3ReferenceFileWithRelayMode = configManager.getConfig('aws:referenceFileWithRelayMode');
+        if (fileUploadType === 'azure') {
+          responseParams.azureTenantId = configManager.getConfig('azure:tenantId');
+          responseParams.azureClientId = configManager.getConfig('azure:clientId');
+          responseParams.azureClientSecret = configManager.getConfig('azure:clientSecret');
+          responseParams.azureStorageAccountName = configManager.getConfig('azure:storageAccountName');
+          responseParams.azureStorageContainerName = configManager.getConfig('azure:storageContainerName');
+          responseParams.azureReferenceFileWithRelayMode = configManager.getConfig('azure:referenceFileWithRelayMode');
+        }
+        const parameters = { action: SupportedAction.ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+        return res.apiv3({ responseParams });
       }
       }
-
-      if (fileUploadType === 'azure') {
-        responseParams.azureTenantId = configManager.getConfig('azure:tenantId');
-        responseParams.azureClientId = configManager.getConfig('azure:clientId');
-        responseParams.azureClientSecret = configManager.getConfig('azure:clientSecret');
-        responseParams.azureStorageAccountName = configManager.getConfig('azure:storageAccountName');
-        responseParams.azureStorageContainerName = configManager.getConfig('azure:storageContainerName');
-        responseParams.azureReferenceFileWithRelayMode = configManager.getConfig('azure:referenceFileWithRelayMode');
+      catch (err) {
+        const msg = 'Error occurred in retrieving file upload configurations';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
       }
       }
-      const parameters = { action: SupportedAction.ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-      return res.apiv3({ responseParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in retrieving file upload configurations';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
-    }
 
 
-  });
+    });
 
 
-  router.put('/page-bulk-export-settings', loginRequiredStrictly, adminRequired, addActivity, validator.pageBulkExportSettings, apiV3FormValidator,
+
+  router.put('/page-bulk-export-settings',
+    accessTokenParser([SCOPE.WRITE.ADMIN.APP]), loginRequiredStrictly, adminRequired, addActivity, validator.pageBulkExportSettings, apiV3FormValidator,
     async(req, res) => {
     async(req, res) => {
       const requestParams = {
       const requestParams = {
         'app:isBulkExportPagesEnabled': req.body.isBulkExportPagesEnabled,
         'app:isBulkExportPagesEnabled': req.body.isBulkExportPagesEnabled,
@@ -1057,26 +1067,27 @@ module.exports = (crowi) => {
    *                      description: is V5 compatible, or not
    *                      description: is V5 compatible, or not
    *                      example: true
    *                      example: true
    */
    */
-  router.post('/v5-schema-migration', accessTokenParser, 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'));
-    }
+  router.post('/v5-schema-migration',
+    accessTokenParser([SCOPE.WRITE.ADMIN.APP], { acceptLegacy: true }), 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'));
+      }
 
 
-    const isV5Compatible = configManager.getConfig('app:isV5Compatible');
+      const isV5Compatible = configManager.getConfig('app:isV5Compatible');
 
 
-    try {
-      if (!isV5Compatible) {
+      try {
+        if (!isV5Compatible) {
         // This method throws and emit socketIo event when error occurs
         // This method throws and emit socketIo event when error occurs
-        crowi.pageService.normalizeAllPublicPages();
+          crowi.pageService.normalizeAllPublicPages();
+        }
+      }
+      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({ isV5Compatible });
-  });
+      return res.apiv3({ isV5Compatible });
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -1111,36 +1122,37 @@ module.exports = (crowi) => {
    *                      description: true if maintenance mode is enabled
    *                      description: true if maintenance mode is enabled
    *                      example: true
    *                      example: true
    */
    */
-  // eslint-disable-next-line max-len
-  router.post('/maintenance-mode', accessTokenParser, loginRequiredStrictly, adminRequired, addActivity, validator.maintenanceMode, apiV3FormValidator, async(req, res) => {
-    const { flag } = req.body;
-    const parameters = {};
-    try {
-      if (flag) {
-        await crowi.appService.startMaintenanceMode();
-        Object.assign(parameters, { action: SupportedAction.ACTION_ADMIN_MAINTENANCEMODE_ENABLED });
-      }
-      else {
-        await crowi.appService.endMaintenanceMode();
-        Object.assign(parameters, { action: SupportedAction.ACTION_ADMIN_MAINTENANCEMODE_DISABLED });
-      }
-    }
-    catch (err) {
-      logger.error(err);
-      if (flag) {
-        res.apiv3Err(new ErrorV3('Failed to start maintenance mode', 'failed_to_start_maintenance_mode'), 500);
+  router.post('/maintenance-mode',
+    accessTokenParser([SCOPE.WRITE.ADMIN.APP], { acceptLegacy: true }),
+    loginRequiredStrictly, adminRequired, addActivity, validator.maintenanceMode, apiV3FormValidator, async(req, res) => {
+      const { flag } = req.body;
+      const parameters = {};
+      try {
+        if (flag) {
+          await crowi.appService.startMaintenanceMode();
+          Object.assign(parameters, { action: SupportedAction.ACTION_ADMIN_MAINTENANCEMODE_ENABLED });
+        }
+        else {
+          await crowi.appService.endMaintenanceMode();
+          Object.assign(parameters, { action: SupportedAction.ACTION_ADMIN_MAINTENANCEMODE_DISABLED });
+        }
       }
       }
-      else {
-        res.apiv3Err(new ErrorV3('Failed to end maintenance mode', 'failed_to_end_maintenance_mode'), 500);
+      catch (err) {
+        logger.error(err);
+        if (flag) {
+          res.apiv3Err(new ErrorV3('Failed to start maintenance mode', 'failed_to_start_maintenance_mode'), 500);
+        }
+        else {
+          res.apiv3Err(new ErrorV3('Failed to end maintenance mode', 'failed_to_end_maintenance_mode'), 500);
+        }
       }
       }
-    }
 
 
-    if ('action' in parameters) {
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-    }
+      if ('action' in parameters) {
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+      }
 
 
-    res.apiv3({ flag });
-  });
+      res.apiv3({ flag });
+    });
 
 
   return router;
   return router;
 };
 };

+ 55 - 49
apps/app/src/server/routes/apiv3/attachment.js

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

+ 65 - 58
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 { Types } from 'mongoose';
 
 
 import type { BookmarkFolderItems } from '~/interfaces/bookmark-info';
 import type { BookmarkFolderItems } from '~/interfaces/bookmark-info';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { InvalidParentBookmarkFolderError } from '~/server/models/errors';
 import { InvalidParentBookmarkFolderError } from '~/server/models/errors';
@@ -156,26 +157,28 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      $ref: '#/components/schemas/BookmarkFolder'
    *                      $ref: '#/components/schemas/BookmarkFolder'
    */
    */
-  router.post('/', accessTokenParser, loginRequiredStrictly, validator.bookmarkFolder, apiV3FormValidator, async(req, res) => {
-    const owner = req.user?._id;
-    const { name, parent } = req.body;
-    const params = {
-      name, owner, parent,
-    };
+  router.post('/',
+    accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }),
+    loginRequiredStrictly, validator.bookmarkFolder, apiV3FormValidator, async(req, res) => {
+      const owner = req.user?._id;
+      const { name, parent } = req.body;
+      const params = {
+        name, owner, parent,
+      };
 
 
-    try {
-      const bookmarkFolder = await BookmarkFolder.createByParameters(params);
-      logger.debug('bookmark folder created', bookmarkFolder);
-      return res.apiv3({ bookmarkFolder });
-    }
-    catch (err) {
-      logger.error(err);
-      if (err instanceof InvalidParentBookmarkFolderError) {
-        return res.apiv3Err(new ErrorV3(err.message, 'failed_to_create_bookmark_folder'));
+      try {
+        const bookmarkFolder = await BookmarkFolder.createByParameters(params);
+        logger.debug('bookmark folder created', bookmarkFolder);
+        return res.apiv3({ bookmarkFolder });
       }
       }
-      return res.apiv3Err(err, 500);
-    }
-  });
+      catch (err) {
+        logger.error(err);
+        if (err instanceof InvalidParentBookmarkFolderError) {
+          return res.apiv3Err(new ErrorV3(err.message, 'failed_to_create_bookmark_folder'));
+        }
+        return res.apiv3Err(err, 500);
+      }
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -208,7 +211,7 @@ module.exports = (crowi) => {
    *                        type: object
    *                        type: object
    *                        $ref: '#/components/schemas/BookmarkFolder'
    *                        $ref: '#/components/schemas/BookmarkFolder'
    */
    */
-  router.get('/list/:userId', accessTokenParser, loginRequiredStrictly, async(req, res) => {
+  router.get('/list/:userId', accessTokenParser([SCOPE.READ.FEATURES.BOOKMARK], { acceptLegacy: true }), loginRequiredStrictly, async(req, res) => {
     const { userId } = req.params;
     const { userId } = req.params;
 
 
     const getBookmarkFolders = async(
     const getBookmarkFolders = async(
@@ -296,7 +299,7 @@ module.exports = (crowi) => {
    *                      description: Number of deleted folders
    *                      description: Number of deleted folders
    *                      example: 1
    *                      example: 1
    */
    */
-  router.delete('/:id', accessTokenParser, loginRequiredStrictly, async(req, res) => {
+  router.delete('/:id', accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }), loginRequiredStrictly, async(req, res) => {
     const { id } = req.params;
     const { id } = req.params;
     try {
     try {
       const result = await BookmarkFolder.deleteFolderAndChildren(id);
       const result = await BookmarkFolder.deleteFolderAndChildren(id);
@@ -352,19 +355,20 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      $ref: '#/components/schemas/BookmarkFolder'
    *                      $ref: '#/components/schemas/BookmarkFolder'
    */
    */
-  router.put('/', accessTokenParser, loginRequiredStrictly, validator.bookmarkFolder, async(req, res) => {
-    const {
-      bookmarkFolderId, name, parent, childFolder,
-    } = req.body;
-    try {
-      const bookmarkFolder = await BookmarkFolder.updateBookmarkFolder(bookmarkFolderId, name, parent, childFolder);
-      return res.apiv3({ bookmarkFolder });
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(err, 500);
-    }
-  });
+  router.put('/',
+    accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }), loginRequiredStrictly, validator.bookmarkFolder, async(req, res) => {
+      const {
+        bookmarkFolderId, name, parent, childFolder,
+      } = req.body;
+      try {
+        const bookmarkFolder = await BookmarkFolder.updateBookmarkFolder(bookmarkFolderId, name, parent, childFolder);
+        return res.apiv3({ bookmarkFolder });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err, 500);
+      }
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -401,20 +405,22 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      $ref: '#/components/schemas/BookmarkFolder'
    *                      $ref: '#/components/schemas/BookmarkFolder'
    */
    */
-  router.post('/add-bookmark-to-folder', accessTokenParser, loginRequiredStrictly, validator.bookmarkPage, apiV3FormValidator, async(req, res) => {
-    const userId = req.user?._id;
-    const { pageId, folderId } = req.body;
+  router.post('/add-bookmark-to-folder',
+    accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }), 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
    * @swagger
@@ -450,17 +456,18 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      $ref: '#/components/schemas/BookmarkFolder'
    *                      $ref: '#/components/schemas/BookmarkFolder'
    */
    */
-  router.put('/update-bookmark', accessTokenParser, loginRequiredStrictly, validator.bookmark, async(req, res) => {
-    const { pageId, status } = req.body;
-    const userId = req.user?._id;
-    try {
-      const bookmarkFolder = await BookmarkFolder.updateBookmark(pageId, status, userId);
-      return res.apiv3({ bookmarkFolder });
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(err, 500);
-    }
-  });
+  router.put('/update-bookmark',
+    accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }), loginRequiredStrictly, validator.bookmark, async(req, res) => {
+      const { pageId, status } = req.body;
+      const userId = req.user?._id;
+      try {
+        const bookmarkFolder = await BookmarkFolder.updateBookmark(pageId, status, userId);
+        return res.apiv3({ bookmarkFolder });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err, 500);
+      }
+    });
   return router;
   return router;
 };
 };

+ 118 - 112
apps/app/src/server/routes/apiv3/bookmarks.js

@@ -1,6 +1,7 @@
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 
 
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { serializeBookmarkSecurely } from '~/server/models/serializers/bookmark-serializer';
 import { serializeBookmarkSecurely } from '~/server/models/serializers/bookmark-serializer';
@@ -124,43 +125,44 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/BookmarkInfo'
    *                  $ref: '#/components/schemas/BookmarkInfo'
    */
    */
-  router.get('/info', accessTokenParser, loginRequired, validator.bookmarkInfo, apiV3FormValidator, async(req, res) => {
-    const { user } = req;
-    const { pageId } = req.query;
+  router.get('/info',
+    accessTokenParser([SCOPE.READ.FEATURES.BOOKMARK], { acceptLegacy: true }), loginRequired, validator.bookmarkInfo, apiV3FormValidator, async(req, res) => {
+      const { user } = req;
+      const { pageId } = req.query;
+
+      const responsesParams = {};
+
+      try {
+        const bookmarks = await Bookmark.find({ page: pageId }).populate('user');
+        let users = [];
+        if (bookmarks.length > 0) {
+          users = bookmarks.map(bookmark => serializeUserSecurely(bookmark.user));
+        }
+        responsesParams.sumOfBookmarks = bookmarks.length;
+        responsesParams.bookmarkedUsers = users;
+        responsesParams.pageId = pageId;
+      }
+      catch (err) {
+        logger.error('get-bookmark-document-failed', err);
+        return res.apiv3Err(err, 500);
+      }
 
 
-    const responsesParams = {};
+      // guest user only get bookmark count
+      if (user == null) {
+        return res.apiv3(responsesParams);
+      }
 
 
-    try {
-      const bookmarks = await Bookmark.find({ page: pageId }).populate('user');
-      let users = [];
-      if (bookmarks.length > 0) {
-        users = bookmarks.map(bookmark => serializeUserSecurely(bookmark.user));
+      try {
+        const bookmark = await Bookmark.findByPageIdAndUserId(pageId, user._id);
+        responsesParams.isBookmarked = (bookmark != null);
+        return res.apiv3(responsesParams);
+      }
+      catch (err) {
+        logger.error('get-bookmark-state-failed', err);
+        return res.apiv3Err(err, 500);
       }
       }
-      responsesParams.sumOfBookmarks = bookmarks.length;
-      responsesParams.bookmarkedUsers = users;
-      responsesParams.pageId = pageId;
-    }
-    catch (err) {
-      logger.error('get-bookmark-document-failed', err);
-      return res.apiv3Err(err, 500);
-    }
-
-    // guest user only get bookmark count
-    if (user == null) {
-      return res.apiv3(responsesParams);
-    }
-
-    try {
-      const bookmark = await Bookmark.findByPageIdAndUserId(pageId, user._id);
-      responsesParams.isBookmarked = (bookmark != null);
-      return res.apiv3(responsesParams);
-    }
-    catch (err) {
-      logger.error('get-bookmark-state-failed', err);
-      return res.apiv3Err(err, 500);
-    }
-
-  });
+
+    });
 
 
   // select page from bookmark where userid = userid
   // select page from bookmark where userid = userid
   /**
   /**
@@ -190,36 +192,38 @@ module.exports = (crowi) => {
     param('userId').isMongoId().withMessage('userId is required'),
     param('userId').isMongoId().withMessage('userId is required'),
   ];
   ];
 
 
-  router.get('/:userId', accessTokenParser, loginRequired, validator.userBookmarkList, apiV3FormValidator, async(req, res) => {
-    const { userId } = req.params;
-
-    if (userId == null) {
-      return res.apiv3Err('User id is not found or forbidden', 400);
-    }
-    try {
-      const bookmarkIdsInFolders = await BookmarkFolder.distinct('bookmarks', { owner: userId });
-      const userRootBookmarks = await Bookmark.find({
-        _id: { $nin: bookmarkIdsInFolders },
-        user: userId,
-      }).populate({
-        path: 'page',
-        model: 'Page',
-        populate: {
-          path: 'lastUpdateUser',
-          model: 'User',
-        },
-      }).exec();
-
-      // serialize Bookmark
-      const serializedUserRootBookmarks = userRootBookmarks.map(bookmark => serializeBookmarkSecurely(bookmark));
-
-      return res.apiv3({ userRootBookmarks: serializedUserRootBookmarks });
-    }
-    catch (err) {
-      logger.error('get-bookmark-failed', err);
-      return res.apiv3Err(err, 500);
-    }
-  });
+  router.get('/:userId',
+    accessTokenParser([SCOPE.READ.FEATURES.BOOKMARK], { acceptLegacy: true }),
+    loginRequired, validator.userBookmarkList, apiV3FormValidator, async(req, res) => {
+      const { userId } = req.params;
+
+      if (userId == null) {
+        return res.apiv3Err('User id is not found or forbidden', 400);
+      }
+      try {
+        const bookmarkIdsInFolders = await BookmarkFolder.distinct('bookmarks', { owner: userId });
+        const userRootBookmarks = await Bookmark.find({
+          _id: { $nin: bookmarkIdsInFolders },
+          user: userId,
+        }).populate({
+          path: 'page',
+          model: 'Page',
+          populate: {
+            path: 'lastUpdateUser',
+            model: 'User',
+          },
+        }).exec();
+
+        // serialize Bookmark
+        const serializedUserRootBookmarks = userRootBookmarks.map(bookmark => serializeBookmarkSecurely(bookmark));
+
+        return res.apiv3({ userRootBookmarks: serializedUserRootBookmarks });
+      }
+      catch (err) {
+        logger.error('get-bookmark-failed', err);
+        return res.apiv3Err(err, 500);
+      }
+    });
 
 
 
 
   /**
   /**
@@ -246,62 +250,64 @@ module.exports = (crowi) => {
    *                    bookmark:
    *                    bookmark:
    *                      $ref: '#/components/schemas/Bookmark'
    *                      $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;
-
-    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`);
+  router.put('/',
+    accessTokenParser([SCOPE.WRITE.FEATURES.BOOKMARK], { acceptLegacy: true }), 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.');
       }
       }
 
 
-      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`);
+        }
+
+        bookmark = await Bookmark.findByPageIdAndUserId(page._id, req.user._id);
 
 
-      if (bookmark == null) {
-        if (bool) {
-          bookmark = await Bookmark.add(page, req.user);
+        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 {
         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
         // 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);
-    }
-
-    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,
-    };
-
-    activityEvent.emit('update', res.locals.activity._id, parameters, page, preNotifyService.generatePreNotify);
-
-    return res.apiv3({ bookmark });
-  });
+      catch (err) {
+        logger.error('update-bookmark-failed', err);
+        return res.apiv3Err(err, 500);
+      }
+
+      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,
+      };
+
+      activityEvent.emit('update', res.locals.activity._id, parameters, page, preNotifyService.generatePreNotify);
+
+      return res.apiv3({ bookmark });
+    });
 
 
   return router;
   return router;
 };
 };

+ 264 - 241
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 { GrowiPlugin } from '~/features/growi-plugin/server/models';
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { AttachmentType } from '~/server/interfaces/attachment';
 import { AttachmentType } from '~/server/interfaces/attachment';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { Attachment } from '~/server/models/attachment';
 import { Attachment } from '~/server/models/attachment';
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -273,7 +275,7 @@ module.exports = (crowi) => {
    *                      description: customize params
    *                      description: customize params
    *                      $ref: '#/components/schemas/CustomizeSetting'
    *                      $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 = {
     const customizeParams = {
       isEnabledTimeline: await configManager.getConfig('customize:isEnabledTimeline'),
       isEnabledTimeline: await configManager.getConfig('customize:isEnabledTimeline'),
       isEnabledAttachTitleHeader: await configManager.getConfig('customize:isEnabledAttachTitleHeader'),
       isEnabledAttachTitleHeader: await configManager.getConfig('customize:isEnabledAttachTitleHeader'),
@@ -315,7 +317,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/CustomizeLayout'
    *                  $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 {
     try {
       const isContainerFluid = await configManager.getConfig('customize:isContainerFluid');
       const isContainerFluid = await configManager.getConfig('customize:isContainerFluid');
       return res.apiv3({ isContainerFluid });
       return res.apiv3({ isContainerFluid });
@@ -354,28 +356,30 @@ module.exports = (crowi) => {
    *                      description: customized params
    *                      description: customized params
    *                      $ref: '#/components/schemas/CustomizeLayout'
    *                      $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
    * @swagger
@@ -404,7 +408,7 @@ module.exports = (crowi) => {
    *                      items:
    *                      items:
    *                        $ref: '#/components/schemas/ThemesMetadata'
    *                        $ref: '#/components/schemas/ThemesMetadata'
    */
    */
-  router.get('/theme', loginRequiredStrictly, async(req, res) => {
+  router.get('/theme', accessTokenParser([SCOPE.READ.ADMIN.CUSTOMIZE]), loginRequiredStrictly, async(req, res) => {
 
 
     try {
     try {
       const currentTheme = await configManager.getConfig('customize:theme');
       const currentTheme = await configManager.getConfig('customize:theme');
@@ -452,27 +456,28 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeTheme'
    *                      $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
    * @swagger
@@ -492,7 +497,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/CustomizeSidebar'
    *                  $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 {
     try {
       const isSidebarCollapsedMode = await configManager.getConfig('customize:isSidebarCollapsedMode');
       const isSidebarCollapsedMode = await configManager.getConfig('customize:isSidebarCollapsedMode');
@@ -533,29 +538,31 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeSidebar'
    *                      $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
    * @swagger
@@ -584,44 +591,46 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeFunction'
    *                      $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'));
+      }
+    });
 
 
 
 
   /**
   /**
@@ -651,26 +660,28 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizePresentation'
    *                      $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
    * @swagger
@@ -699,28 +710,30 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeHighlightResponse'
    *                      $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
    * @swagger
@@ -749,29 +762,31 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeTitle'
    *                      $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
    * @swagger
@@ -800,25 +815,27 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeNoscript'
    *                      $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
    * @swagger
@@ -847,28 +864,30 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeCss'
    *                      $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
    * @swagger
@@ -897,25 +916,27 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeScript'
    *                      $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
    * @swagger
@@ -944,28 +965,29 @@ module.exports = (crowi) => {
    *                    customizedParams:
    *                    customizedParams:
    *                      $ref: '#/components/schemas/CustomizeLogo'
    *                      $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
    * @swagger
@@ -1006,8 +1028,9 @@ module.exports = (crowi) => {
    *                            temporaryUrlExpiredAt: {}
    *                            temporaryUrlExpiredAt: {}
    *                            temporaryUrlCached: {}
    *                            temporaryUrlCached: {}
    */
    */
-  router.post('/upload-brand-logo', loginRequiredStrictly, adminRequired,
-    uploads.single('file'), validator.logo, apiV3FormValidator, async(req, res) => {
+  router.post('/upload-brand-logo',
+    uploads.single('file'), accessTokenParser([SCOPE.WRITE.ADMIN.CUSTOMIZE]), loginRequiredStrictly, uploads.single('file'), validator.logo, apiV3FormValidator,
+    async(req, res) => {
 
 
       if (req.file == null) {
       if (req.file == null) {
         return res.apiv3Err(new ErrorV3('File error.', 'upload-brand-logo-failed'));
         return res.apiv3Err(new ErrorV3('File error.', 'upload-brand-logo-failed'));
@@ -1061,7 +1084,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  additionalProperties: false
    *                  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 });
     const attachments = await Attachment.find({ attachmentType: AttachmentType.BRAND_LOGO });
 
 

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

@@ -1,4 +1,7 @@
+import sanitize from 'sanitize-filename';
+
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { exportService } from '~/server/service/export';
 import { exportService } from '~/server/service/export';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -171,7 +174,7 @@ module.exports = (crowi) => {
    *                  status:
    *                  status:
    *                    $ref: '#/components/schemas/ExportStatus'
    *                    $ref: '#/components/schemas/ExportStatus'
    */
    */
-  router.get('/status', accessTokenParser, loginRequired, adminRequired, async(req, res) => {
+  router.get('/status', accessTokenParser([SCOPE.READ.ADMIN.EXPORT_DATA], { acceptLegacy: true }), loginRequired, adminRequired, async(req, res) => {
     const status = await exportService.getStatus();
     const status = await exportService.getStatus();
 
 
     // TODO: use res.apiv3
     // TODO: use res.apiv3
@@ -211,7 +214,7 @@ module.exports = (crowi) => {
    *                    type: boolean
    *                    type: boolean
    *                    description: whether the request is succeeded
    *                    description: whether the request is succeeded
    */
    */
-  router.post('/', accessTokenParser, loginRequired, adminRequired, addActivity, async(req, res) => {
+  router.post('/', accessTokenParser([SCOPE.WRITE.ADMIN.EXPORT_DATA], { acceptLegacy: true }), loginRequired, adminRequired, addActivity, async(req, res) => {
     // TODO: add express validator
     // TODO: add express validator
     try {
     try {
       const { collections } = req.body;
       const { collections } = req.body;
@@ -260,25 +263,28 @@ module.exports = (crowi) => {
    *                    type: boolean
    *                    type: boolean
    *                    description: whether the request is succeeded
    *                    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.EXPORT_DATA], { acceptLegacy: true }), loginRequired, adminRequired,
+    validator.deleteFile, apiV3FormValidator, addActivity,
+    async(req, res) => {
     // TODO: add express validator
     // 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 sanitizedFileName = sanitize(fileName);
+        const zipFile = exportService.getFile(sanitizedFileName);
+        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
       // 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;
   return router;
 };
 };

+ 57 - 56
apps/app/src/server/routes/apiv3/g2g-transfer.ts

@@ -7,6 +7,7 @@ import express from 'express';
 import { body } from 'express-validator';
 import { body } from 'express-validator';
 import multer from 'multer';
 import multer from 'multer';
 
 
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { isG2GTransferError } from '~/server/models/vo/g2g-transfer-error';
 import { isG2GTransferError } from '~/server/models/vo/g2g-transfer-error';
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
@@ -198,7 +199,6 @@ module.exports = (crowi: Crowi): Router => {
    *                          type: number
    *                          type: number
    *                          description: The size of the file
    *                          description: The size of the file
    */
    */
-  // eslint-disable-next-line max-len
   receiveRouter.get('/files', validateTransferKey, async(req: Request, res: ApiV3Response) => {
   receiveRouter.get('/files', validateTransferKey, async(req: Request, res: ApiV3Response) => {
     const files = await crowi.fileUploadService.listFiles();
     const files = await crowi.fileUploadService.listFiles();
     return res.apiv3({ files });
     return res.apiv3({ files });
@@ -250,7 +250,6 @@ module.exports = (crowi: Crowi): Router => {
    *                    type: string
    *                    type: string
    *                    description: The message of the result
    *                    description: The message of the result
    */
    */
-  // eslint-disable-next-line max-len
   receiveRouter.post('/', validateTransferKey, uploads.single('transferDataZipFile'), async(req: Request & { file: any; }, res: ApiV3Response) => {
   receiveRouter.post('/', validateTransferKey, uploads.single('transferDataZipFile'), async(req: Request & { file: any; }, res: ApiV3Response) => {
     const { file } = req;
     const { file } = req;
     const {
     const {
@@ -489,31 +488,32 @@ module.exports = (crowi: Crowi): Router => {
    *                    type: string
    *                    type: string
    *                    description: The transfer key
    *                    description: The transfer key
    */
    */
-  // eslint-disable-next-line max-len
-  receiveRouter.post('/generate-key', accessTokenParser, adminRequiredIfInstalled, appSiteUrlRequiredIfNotInstalled, async(req: Request, res: ApiV3Response) => {
-    const appSiteUrl = req.body.appSiteUrl ?? configManager.getConfig('app:siteUrl');
+  receiveRouter.post('/generate-key',
+    accessTokenParser([SCOPE.WRITE.ADMIN.EXPORT_DATA], { acceptLegacy: true }),
+    adminRequiredIfInstalled, appSiteUrlRequiredIfNotInstalled, async(req: Request, res: ApiV3Response) => {
+      const appSiteUrl = req.body.appSiteUrl ?? configManager.getConfig('app:siteUrl');
 
 
-    let appSiteUrlOrigin: string;
-    try {
-      appSiteUrlOrigin = new URL(appSiteUrl).origin;
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(new ErrorV3('appSiteUrl may be wrong', 'failed_to_generate_key_string'));
-    }
+      let appSiteUrlOrigin: string;
+      try {
+        appSiteUrlOrigin = new URL(appSiteUrl).origin;
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3('appSiteUrl may be wrong', 'failed_to_generate_key_string'));
+      }
 
 
-    // Save TransferKey document
-    let transferKeyString: string;
-    try {
-      transferKeyString = await g2gTransferReceiverService.createTransferKey(appSiteUrlOrigin);
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(new ErrorV3('Error occurred while generating transfer key.', 'failed_to_generate_key'));
-    }
+      // Save TransferKey document
+      let transferKeyString: string;
+      try {
+        transferKeyString = await g2gTransferReceiverService.createTransferKey(appSiteUrlOrigin);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3('Error occurred while generating transfer key.', 'failed_to_generate_key'));
+      }
 
 
-    return res.apiv3({ transferKey: transferKeyString });
-  });
+      return res.apiv3({ transferKey: transferKeyString });
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -555,43 +555,44 @@ module.exports = (crowi: Crowi): Router => {
    *                    type: string
    *                    type: string
    *                    description: The message of the result
    *                    description: The message of the result
    */
    */
-  // eslint-disable-next-line max-len
-  pushRouter.post('/transfer', accessTokenParser, loginRequiredStrictly, adminRequired, validator.transfer, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    const { transferKey, collections, optionsMap } = req.body;
+  pushRouter.post('/transfer',
+    accessTokenParser([SCOPE.WRITE.ADMIN.EXPORT_DATA], { acceptLegacy: true }),
+    loginRequiredStrictly, adminRequired, validator.transfer, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
+      const { transferKey, collections, optionsMap } = req.body;
 
 
-    // Parse transfer key
-    let tk: TransferKey;
-    try {
-      tk = TransferKey.parse(transferKey);
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(new ErrorV3('Transfer key is invalid', 'transfer_key_invalid'), 400);
-    }
+      // Parse transfer key
+      let tk: TransferKey;
+      try {
+        tk = TransferKey.parse(transferKey);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3('Transfer key is invalid', 'transfer_key_invalid'), 400);
+      }
 
 
-    // get growi info
-    let destGROWIInfo: IDataGROWIInfo;
-    try {
-      destGROWIInfo = await g2gTransferPusherService.askGROWIInfo(tk);
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(new ErrorV3('Error occurred while asking GROWI info.', 'failed_to_ask_growi_info'));
-    }
+      // get growi info
+      let destGROWIInfo: IDataGROWIInfo;
+      try {
+        destGROWIInfo = await g2gTransferPusherService.askGROWIInfo(tk);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3('Error occurred while asking GROWI info.', 'failed_to_ask_growi_info'));
+      }
 
 
-    // Check if can transfer
-    const transferability = await g2gTransferPusherService.getTransferability(destGROWIInfo);
-    if (!transferability.canTransfer) {
-      return res.apiv3Err(new ErrorV3(transferability.reason, 'growi_incompatible_to_transfer'));
-    }
+      // Check if can transfer
+      const transferability = await g2gTransferPusherService.getTransferability(destGROWIInfo);
+      if (!transferability.canTransfer) {
+        return res.apiv3Err(new ErrorV3(transferability.reason, 'growi_incompatible_to_transfer'));
+      }
 
 
-    // Start transfer
-    // DO NOT "await". Let it run in the background.
-    // Errors should be emitted through websocket.
-    g2gTransferPusherService.startTransfer(tk, req.user, collections, optionsMap, destGROWIInfo);
+      // Start transfer
+      // DO NOT "await". Let it run in the background.
+      // Errors should be emitted through websocket.
+      g2gTransferPusherService.startTransfer(tk, req.user, collections, optionsMap, destGROWIInfo);
 
 
-    return res.apiv3({ message: 'Successfully requested auto transfer.' });
-  });
+      return res.apiv3({ message: 'Successfully requested auto transfer.' });
+    });
 
 
   // Merge receiveRouter and pushRouter
   // Merge receiveRouter and pushRouter
   router.use(receiveRouter, pushRouter);
   router.use(receiveRouter, pushRouter);

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

@@ -1,6 +1,7 @@
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 
 
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { getImportService } from '~/server/service/import';
 import { getImportService } from '~/server/service/import';
 import { generateOverwriteParams } from '~/server/service/import/overwrite-params';
 import { generateOverwriteParams } from '~/server/service/import/overwrite-params';
@@ -197,7 +198,7 @@ export default function route(crowi) {
    *                        type: string
    *                        type: string
    *                        description: the access token of qiita.com
    *                        description: the access token of qiita.com
    */
    */
-  router.get('/', accessTokenParser, loginRequired, adminRequired, async(req, res) => {
+  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.IMPORT_DATA], { acceptLegacy: true }), loginRequired, adminRequired, async(req, res) => {
     try {
     try {
       const importSettingsParams = {
       const importSettingsParams = {
         esaTeamName: await crowi.configManager.getConfig('importer:esa:team_name'),
         esaTeamName: await crowi.configManager.getConfig('importer:esa:team_name'),
@@ -235,7 +236,7 @@ export default function route(crowi) {
    *                  status:
    *                  status:
    *                    $ref: '#/components/schemas/ImportStatus'
    *                    $ref: '#/components/schemas/ImportStatus'
    */
    */
-  router.get('/status', accessTokenParser, loginRequired, adminRequired, async(req, res) => {
+  router.get('/status', accessTokenParser([SCOPE.READ.ADMIN.IMPORT_DATA], { acceptLegacy: true }), loginRequired, adminRequired, async(req, res) => {
     try {
     try {
       const status = await importService.getStatus();
       const status = await importService.getStatus();
       return res.apiv3(status);
       return res.apiv3(status);
@@ -282,7 +283,7 @@ export default function route(crowi) {
    *        200:
    *        200:
    *          description: Import process has requested
    *          description: Import process has requested
    */
    */
-  router.post('/', accessTokenParser, loginRequired, adminRequired, addActivity, async(req, res) => {
+  router.post('/', accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA], { acceptLegacy: true }), loginRequired, adminRequired, addActivity, async(req, res) => {
     // TODO: add express validator
     // TODO: add express validator
     const { fileName, collections, options } = req.body;
     const { fileName, collections, options } = req.body;
 
 
@@ -405,34 +406,36 @@ export default function route(crowi) {
    *              schema:
    *              schema:
    *                $ref: '#/components/schemas/FileImportResponse'
    *                $ref: '#/components/schemas/FileImportResponse'
    */
    */
-  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], { acceptLegacy: true }), 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
       // 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
       // 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
    * @swagger
@@ -449,7 +452,7 @@ export default function route(crowi) {
    *        200:
    *        200:
    *          description: all files are deleted
    *          description: all files are deleted
    */
    */
-  router.delete('/all', accessTokenParser, loginRequired, adminRequired, async(req, res) => {
+  router.delete('/all', accessTokenParser([SCOPE.WRITE.ADMIN.IMPORT_DATA], { acceptLegacy: true }), loginRequired, adminRequired, async(req, res) => {
     try {
     try {
       importService.deleteAllZipFiles();
       importService.deleteAllZipFiles();
 
 

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

@@ -3,6 +3,7 @@ import express from 'express';
 
 
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 
 
@@ -96,6 +97,7 @@ module.exports = (crowi) => {
 
 
   const activityEvent = crowi.event('activity');
   const activityEvent = crowi.event('activity');
 
 
+
   /**
   /**
    * @swagger
    * @swagger
    *
    *
@@ -131,60 +133,62 @@ module.exports = (crowi) => {
    *              schema:
    *              schema:
    *                $ref: '#/components/schemas/InAppNotificationListResponse'
    *                $ref: '#/components/schemas/InAppNotificationListResponse'
    */
    */
-  router.get('/list', accessTokenParser, loginRequiredStrictly, async(req: CrowiRequest, res: ApiV3Response) => {
+  router.get('/list', accessTokenParser([SCOPE.READ.USER_SETTINGS.IN_APP_NOTIFICATION], { acceptLegacy: true }), loginRequiredStrictly,
+    async(req: CrowiRequest, res: ApiV3Response) => {
     // user must be set by loginRequiredStrictly
     // user must be set by loginRequiredStrictly
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    const user = req.user!;
+      const user = req.user!;
 
 
-    const limit = req.query.limit != null
-      ? parseInt(req.query.limit.toString()) || 10
-      : 10;
+      const limit = req.query.limit != null
+        ? parseInt(req.query.limit.toString()) || 10
+        : 10;
 
 
-    let offset = 0;
-    if (req.query.offset != null) {
-      offset = parseInt(req.query.offset.toString(), 10);
-    }
+      let offset = 0;
+      if (req.query.offset != null) {
+        offset = parseInt(req.query.offset.toString(), 10);
+      }
 
 
-    const queryOptions = {
-      offset,
-      limit,
-    };
+      const queryOptions = {
+        offset,
+        limit,
+      };
 
 
-    // set in-app-notification status to categorize
-    if (req.query.status != null) {
-      Object.assign(queryOptions, { status: req.query.status });
-    }
+      // set in-app-notification status to categorize
+      if (req.query.status != null) {
+        Object.assign(queryOptions, { status: req.query.status });
+      }
 
 
-    const paginationResult = await inAppNotificationService.getLatestNotificationsByUser(user._id, queryOptions);
+      const paginationResult = await inAppNotificationService.getLatestNotificationsByUser(user._id, queryOptions);
 
 
 
 
-    const getActionUsersFromActivities = function(activities) {
-      return activities.map(({ user }) => user).filter((user, i, self) => self.indexOf(user) === i);
-    };
+      const getActionUsersFromActivities = function(activities) {
+        return activities.map(({ user }) => user).filter((user, i, self) => self.indexOf(user) === i);
+      };
 
 
-    const serializedDocs: Array<IInAppNotification> = paginationResult.docs.map((doc) => {
-      if (doc.user != null && doc.user instanceof User) {
-        doc.user = serializeUserSecurely(doc.user);
-      }
-      // To add a new property into mongoose doc, need to change the format of doc to an object
-      const docObj: IInAppNotification = doc.toObject();
-      const actionUsersNew = getActionUsersFromActivities(doc.activities);
+      const serializedDocs: Array<IInAppNotification> = paginationResult.docs.map((doc) => {
+        if (doc.user != null && doc.user instanceof User) {
+          doc.user = serializeUserSecurely(doc.user);
+        }
+        // To add a new property into mongoose doc, need to change the format of doc to an object
+        const docObj: IInAppNotification = doc.toObject();
+        const actionUsersNew = getActionUsersFromActivities(doc.activities);
 
 
-      const serializedActionUsers = actionUsersNew.map((actionUser) => {
-        return serializeUserSecurely(actionUser);
+        const serializedActionUsers = actionUsersNew.map((actionUser) => {
+          return serializeUserSecurely(actionUser);
+        });
+
+        docObj.actionUsers = serializedActionUsers;
+        return docObj;
       });
       });
 
 
-      docObj.actionUsers = serializedActionUsers;
-      return docObj;
-    });
+      const serializedPaginationResult = {
+        ...paginationResult,
+        docs: serializedDocs,
+      };
 
 
-    const serializedPaginationResult = {
-      ...paginationResult,
-      docs: serializedDocs,
-    };
+      return res.apiv3(serializedPaginationResult);
+    });
 
 
-    return res.apiv3(serializedPaginationResult);
-  });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -208,19 +212,20 @@ module.exports = (crowi) => {
    *                    type: integer
    *                    type: integer
    *                    description: Count of unread notifications
    *                    description: Count of unread notifications
    */
    */
-  router.get('/status', accessTokenParser, loginRequiredStrictly, async(req: CrowiRequest, res: ApiV3Response) => {
+  router.get('/status', accessTokenParser([SCOPE.READ.USER_SETTINGS.IN_APP_NOTIFICATION], { acceptLegacy: true }), loginRequiredStrictly,
+    async(req: CrowiRequest, res: ApiV3Response) => {
     // user must be set by loginRequiredStrictly
     // user must be set by loginRequiredStrictly
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    const user = req.user!;
+      const user = req.user!;
 
 
-    try {
-      const count = await inAppNotificationService.getUnreadCountByUser(user._id);
-      return res.apiv3({ count });
-    }
-    catch (err) {
-      return res.apiv3Err(err);
-    }
-  });
+      try {
+        const count = await inAppNotificationService.getUnreadCountByUser(user._id);
+        return res.apiv3({ count });
+      }
+      catch (err) {
+        return res.apiv3Err(err);
+      }
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -251,22 +256,23 @@ module.exports = (crowi) => {
    *              schema:
    *              schema:
    *                type: object
    *                type: object
    */
    */
-  router.post('/open', accessTokenParser, loginRequiredStrictly, async(req: CrowiRequest, res: ApiV3Response) => {
+  router.post('/open', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.IN_APP_NOTIFICATION], { acceptLegacy: true }), loginRequiredStrictly,
+    async(req: CrowiRequest, res: ApiV3Response) => {
     // user must be set by loginRequiredStrictly
     // user must be set by loginRequiredStrictly
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    const user = req.user!;
+      const user = req.user!;
 
 
-    const id = req.body.id;
+      const id = req.body.id;
 
 
-    try {
-      const notification = await inAppNotificationService.open(user, id);
-      const result = { notification };
-      return res.apiv3(result);
-    }
-    catch (err) {
-      return res.apiv3Err(err);
-    }
-  });
+      try {
+        const notification = await inAppNotificationService.open(user, id);
+        const result = { notification };
+        return res.apiv3(result);
+      }
+      catch (err) {
+        return res.apiv3Err(err);
+      }
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -283,22 +289,24 @@ module.exports = (crowi) => {
    *        200:
    *        200:
    *          description: All notifications opened successfully
    *          description: All notifications opened successfully
    */
    */
-  router.put('/all-statuses-open', accessTokenParser, loginRequiredStrictly, addActivity, async(req: CrowiRequest, res: ApiV3Response) => {
+  router.put('/all-statuses-open',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.IN_APP_NOTIFICATION], { acceptLegacy: true }), loginRequiredStrictly, addActivity,
+    async(req: CrowiRequest, res: ApiV3Response) => {
     // user must be set by loginRequiredStrictly
     // user must be set by loginRequiredStrictly
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     // 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;
   return router;
 };
 };

+ 88 - 84
apps/app/src/server/routes/apiv3/markdown-setting.js

@@ -1,13 +1,14 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 
 
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
-
 const logger = loggerFactory('growi:routes:apiv3:markdown-setting');
 const logger = loggerFactory('growi:routes:apiv3:markdown-setting');
 
 
 const express = require('express');
 const express = require('express');
@@ -149,7 +150,7 @@ module.exports = (crowi) => {
    *                      description: markdown params
    *                      description: markdown params
    *                      $ref: '#/components/schemas/MarkdownParams'
    *                      $ref: '#/components/schemas/MarkdownParams'
    */
    */
-  router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.MARKDOWN]), loginRequiredStrictly, adminRequired, async(req, res) => {
     const markdownParams = {
     const markdownParams = {
       isEnabledLinebreaks: await crowi.configManager.getConfig('markdown:isEnabledLinebreaks'),
       isEnabledLinebreaks: await crowi.configManager.getConfig('markdown:isEnabledLinebreaks'),
       isEnabledLinebreaksInComments: await crowi.configManager.getConfig('markdown:isEnabledLinebreaksInComments'),
       isEnabledLinebreaksInComments: await crowi.configManager.getConfig('markdown:isEnabledLinebreaksInComments'),
@@ -191,32 +192,33 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      $ref: '#/components/schemas/LineBreakParams'
    *                      $ref: '#/components/schemas/LineBreakParams'
    */
    */
-  router.put('/lineBreak', loginRequiredStrictly, adminRequired, addActivity, validator.lineBreak, apiV3FormValidator, async(req, res) => {
-
-    const requestLineBreakParams = {
-      'markdown:isEnabledLinebreaks': req.body.isEnabledLinebreaks,
-      'markdown:isEnabledLinebreaksInComments': req.body.isEnabledLinebreaksInComments,
-    };
+  router.put('/lineBreak', accessTokenParser([SCOPE.WRITE.ADMIN.MARKDOWN]),
+    loginRequiredStrictly, adminRequired, addActivity, validator.lineBreak, apiV3FormValidator, async(req, res) => {
 
 
-    try {
-      await configManager.updateConfigs(requestLineBreakParams);
-      const lineBreaksParams = {
-        isEnabledLinebreaks: await crowi.configManager.getConfig('markdown:isEnabledLinebreaks'),
-        isEnabledLinebreaksInComments: await crowi.configManager.getConfig('markdown:isEnabledLinebreaksInComments'),
+      const requestLineBreakParams = {
+        'markdown:isEnabledLinebreaks': req.body.isEnabledLinebreaks,
+        'markdown:isEnabledLinebreaksInComments': req.body.isEnabledLinebreaksInComments,
       };
       };
 
 
-      const parameters = { action: SupportedAction.ACTION_ADMIN_MARKDOWN_LINE_BREAK_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
+      try {
+        await configManager.updateConfigs(requestLineBreakParams);
+        const lineBreaksParams = {
+          isEnabledLinebreaks: await crowi.configManager.getConfig('markdown:isEnabledLinebreaks'),
+          isEnabledLinebreaksInComments: await crowi.configManager.getConfig('markdown:isEnabledLinebreaksInComments'),
+        };
 
 
-      return res.apiv3({ lineBreaksParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating lineBreak';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-lineBreak-failed'));
-    }
+        const parameters = { action: SupportedAction.ACTION_ADMIN_MARKDOWN_LINE_BREAK_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
-  });
+        return res.apiv3({ lineBreaksParams });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating lineBreak';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-lineBreak-failed'));
+      }
+
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -246,32 +248,33 @@ module.exports = (crowi) => {
    *                      description: indent params
    *                      description: indent params
    *                      $ref: '#/components/schemas/IndentParams'
    *                      $ref: '#/components/schemas/IndentParams'
    */
    */
-  router.put('/indent', loginRequiredStrictly, adminRequired, addActivity, validator.indent, apiV3FormValidator, async(req, res) => {
-
-    const requestIndentParams = {
-      'markdown:adminPreferredIndentSize': req.body.adminPreferredIndentSize,
-      'markdown:isIndentSizeForced': req.body.isIndentSizeForced,
-    };
+  router.put('/indent', accessTokenParser([SCOPE.WRITE.ADMIN.MARKDOWN]),
+    loginRequiredStrictly, adminRequired, addActivity, validator.indent, apiV3FormValidator, async(req, res) => {
 
 
-    try {
-      await configManager.updateConfigs(requestIndentParams);
-      const indentParams = {
-        adminPreferredIndentSize: await crowi.configManager.getConfig('markdown:adminPreferredIndentSize'),
-        isIndentSizeForced: await crowi.configManager.getConfig('markdown:isIndentSizeForced'),
+      const requestIndentParams = {
+        'markdown:adminPreferredIndentSize': req.body.adminPreferredIndentSize,
+        'markdown:isIndentSizeForced': req.body.isIndentSizeForced,
       };
       };
 
 
-      const parameters = { action: SupportedAction.ACTION_ADMIN_MARKDOWN_INDENT_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
+      try {
+        await configManager.updateConfigs(requestIndentParams);
+        const indentParams = {
+          adminPreferredIndentSize: await crowi.configManager.getConfig('markdown:adminPreferredIndentSize'),
+          isIndentSizeForced: await crowi.configManager.getConfig('markdown:isIndentSizeForced'),
+        };
 
 
-      return res.apiv3({ indentParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating indent';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-indent-failed'));
-    }
+        const parameters = { action: SupportedAction.ACTION_ADMIN_MARKDOWN_INDENT_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
-  });
+        return res.apiv3({ indentParams });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating indent';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-indent-failed'));
+      }
+
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -297,48 +300,49 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/XssParams'
    *                  $ref: '#/components/schemas/XssParams'
    */
    */
-  router.put('/xss', loginRequiredStrictly, adminRequired, addActivity, validator.xssSetting, apiV3FormValidator, async(req, res) => {
-    if (req.body.isEnabledXss && req.body.xssOption == null) {
-      return res.apiv3Err(new ErrorV3('xss option is required'));
-    }
-
-    try {
-      JSON.parse(req.body.attrWhitelist);
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating xss';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-xss-failed'));
-    }
-
-    const reqestXssParams = {
-      'markdown:rehypeSanitize:isEnabledPrevention': req.body.isEnabledXss,
-      'markdown:rehypeSanitize:option': req.body.xssOption,
-      'markdown:rehypeSanitize:tagNames': req.body.tagWhitelist,
-      'markdown:rehypeSanitize:attributes': req.body.attrWhitelist,
-    };
-
-    try {
-      await configManager.updateConfigs(reqestXssParams);
-      const xssParams = {
-        isEnabledXss: await crowi.configManager.getConfig('markdown:rehypeSanitize:isEnabledPrevention'),
-        xssOption: await crowi.configManager.getConfig('markdown:rehypeSanitize:option'),
-        tagWhitelist: await crowi.configManager.getConfig('markdown:rehypeSanitize:tagNames'),
-        attrWhitelist: await crowi.configManager.getConfig('markdown:rehypeSanitize:attributes'),
+  router.put('/xss', accessTokenParser([SCOPE.WRITE.ADMIN.MARKDOWN]),
+    loginRequiredStrictly, adminRequired, addActivity, validator.xssSetting, apiV3FormValidator, async(req, res) => {
+      if (req.body.isEnabledXss && req.body.xssOption == null) {
+        return res.apiv3Err(new ErrorV3('xss option is required'));
+      }
+
+      try {
+        JSON.parse(req.body.attrWhitelist);
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating xss';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-xss-failed'));
+      }
+
+      const reqestXssParams = {
+        'markdown:rehypeSanitize:isEnabledPrevention': req.body.isEnabledXss,
+        'markdown:rehypeSanitize:option': req.body.xssOption,
+        'markdown:rehypeSanitize:tagNames': req.body.tagWhitelist,
+        'markdown:rehypeSanitize:attributes': req.body.attrWhitelist,
       };
       };
 
 
-      const parameters = { action: SupportedAction.ACTION_ADMIN_MARKDOWN_XSS_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-
-      return res.apiv3({ xssParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating xss';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-xss-failed'));
-    }
-
-  });
+      try {
+        await configManager.updateConfigs(reqestXssParams);
+        const xssParams = {
+          isEnabledXss: await crowi.configManager.getConfig('markdown:rehypeSanitize:isEnabledPrevention'),
+          xssOption: await crowi.configManager.getConfig('markdown:rehypeSanitize:option'),
+          tagWhitelist: await crowi.configManager.getConfig('markdown:rehypeSanitize:tagNames'),
+          attrWhitelist: await crowi.configManager.getConfig('markdown:rehypeSanitize:attributes'),
+        };
+
+        const parameters = { action: SupportedAction.ACTION_ADMIN_MARKDOWN_XSS_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3({ xssParams });
+      }
+      catch (err) {
+        const msg = 'Error occurred in updating xss';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-xss-failed'));
+      }
+
+    });
 
 
   return router;
   return router;
 };
 };

+ 4 - 1
apps/app/src/server/routes/apiv3/mongo.js

@@ -1,3 +1,6 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
+
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 const logger = loggerFactory('growi:routes:apiv3:mongo'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:mongo'); // eslint-disable-line no-unused-vars
@@ -35,7 +38,7 @@ module.exports = (crowi) => {
    *                    items:
    *                    items:
    *                      type: string
    *                      type: string
    */
    */
-  router.get('/collections', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.get('/collections', accessTokenParser([SCOPE.READ.ADMIN.EXPORT_DATA]), loginRequiredStrictly, adminRequired, async(req, res) => {
     const listCollectionsResult = await mongoose.connection.db.listCollections().toArray();
     const listCollectionsResult = await mongoose.connection.db.listCollections().toArray();
     const collections = listCollectionsResult.map(collectionObj => collectionObj.name);
     const collections = listCollectionsResult.map(collectionObj => collectionObj.name);
 
 

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

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

+ 113 - 107
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -2,6 +2,7 @@ import type {
   IPageInfoForListing, IPageInfo, IPage, IUserHasId,
   IPageInfoForListing, IPageInfo, IPage, IUserHasId,
 } from '@growi/core';
 } from '@growi/core';
 import { getIdForRef, isIPageInfoForEntity } from '@growi/core';
 import { getIdForRef, isIPageInfoForEntity } from '@growi/core';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, Router } from 'express';
 import type { Request, Router } from 'express';
 import express from 'express';
 import express from 'express';
@@ -65,6 +66,7 @@ const routerFactory = (crowi: Crowi): Router => {
 
 
   const router = express.Router();
   const router = express.Router();
 
 
+
   /**
   /**
    * @swagger
    * @swagger
    *
    *
@@ -87,19 +89,20 @@ const routerFactory = (crowi: Crowi): Router => {
    *                 rootPage:
    *                 rootPage:
    *                   $ref: '#/components/schemas/Page'
    *                   $ref: '#/components/schemas/Page'
    */
    */
-  router.get('/root', accessTokenParser, loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    const Page = mongoose.model<IPage, PageModel>('Page');
+  router.get('/root',
+    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }), loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+      const Page = mongoose.model<IPage, PageModel>('Page');
 
 
-    let rootPage;
-    try {
-      rootPage = await Page.findByPathAndViewer('/', req.user, null, true);
-    }
-    catch (err) {
-      return res.apiv3Err(new ErrorV3('rootPage not found'));
-    }
+      let rootPage;
+      try {
+        rootPage = await Page.findByPathAndViewer('/', req.user, null, true);
+      }
+      catch (err) {
+        return res.apiv3Err(new ErrorV3('rootPage not found'));
+      }
 
 
-    return res.apiv3({ rootPage });
-  });
+      return res.apiv3({ rootPage });
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -151,21 +154,22 @@ const routerFactory = (crowi: Crowi): Router => {
    *                         nullable: true
    *                         nullable: true
    *                         description: Revision ID (nullable)
    *                         description: Revision ID (nullable)
    */
    */
-  // eslint-disable-next-line max-len
-  router.get('/ancestors-children', accessTokenParser, loginRequired, ...validator.pagePathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
-    const { path } = req.query;
-
-    const pageService = crowi.pageService;
-    try {
-      const ancestorsChildren = await pageService.findAncestorsChildrenByPathAndViewer(path as string, req.user);
-      return res.apiv3({ ancestorsChildren });
-    }
-    catch (err) {
-      logger.error('Failed to get ancestorsChildren.', err);
-      return res.apiv3Err(new ErrorV3('Failed to get ancestorsChildren.'));
-    }
-
-  });
+  router.get('/ancestors-children',
+    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequired, ...validator.pagePathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
+      const { path } = req.query;
+
+      const pageService = crowi.pageService;
+      try {
+        const ancestorsChildren = await pageService.findAncestorsChildrenByPathAndViewer(path as string, req.user);
+        return res.apiv3({ ancestorsChildren });
+      }
+      catch (err) {
+        logger.error('Failed to get ancestorsChildren.', err);
+        return res.apiv3Err(new ErrorV3('Failed to get ancestorsChildren.'));
+      }
+
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -203,26 +207,27 @@ const routerFactory = (crowi: Crowi): Router => {
   /*
   /*
    * In most cases, using id should be prioritized
    * In most cases, using id should be prioritized
    */
    */
-  // eslint-disable-next-line max-len
-  router.get('/children', accessTokenParser, loginRequired, validator.pageIdOrPathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    const { id, path } = req.query;
+  router.get('/children',
+    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequired, validator.pageIdOrPathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
+      const { id, path } = req.query;
 
 
-    const pageService = crowi.pageService;
+      const pageService = crowi.pageService;
 
 
-    const hideRestrictedByOwner = await configManager.getConfig('security:list-policy:hideRestrictedByOwner');
-    const hideRestrictedByGroup = await configManager.getConfig('security:list-policy:hideRestrictedByGroup');
+      const hideRestrictedByOwner = await configManager.getConfig('security:list-policy:hideRestrictedByOwner');
+      const hideRestrictedByGroup = await configManager.getConfig('security:list-policy:hideRestrictedByGroup');
 
 
-    try {
-      const pages = await pageService.findChildrenByParentPathOrIdAndViewer(
+      try {
+        const pages = await pageService.findChildrenByParentPathOrIdAndViewer(
         (id || path) as string, req.user, undefined, !hideRestrictedByOwner, !hideRestrictedByGroup,
         (id || path) as string, req.user, undefined, !hideRestrictedByOwner, !hideRestrictedByGroup,
-      );
-      return res.apiv3({ children: pages });
-    }
-    catch (err) {
-      logger.error('Error occurred while finding children.', err);
-      return res.apiv3Err(new ErrorV3('Error occurred while finding children.'));
-    }
-  });
+        );
+        return res.apiv3({ children: pages });
+      }
+      catch (err) {
+        logger.error('Error occurred while finding children.', err);
+        return res.apiv3Err(new ErrorV3('Error occurred while finding children.'));
+      }
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -266,79 +271,80 @@ const routerFactory = (crowi: Crowi): Router => {
    *               additionalProperties:
    *               additionalProperties:
    *                 $ref: '#/components/schemas/PageInfoAll'
    *                 $ref: '#/components/schemas/PageInfoAll'
    */
    */
-  // eslint-disable-next-line max-len
-  router.get('/info', accessTokenParser, validator.pageIdsOrPathRequired, validator.infoParams, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    const {
-      pageIds, path, attachBookmarkCount: attachBookmarkCountParam, attachShortBody: attachShortBodyParam,
-    } = req.query;
-
-    const attachBookmarkCount: boolean = attachBookmarkCountParam === 'true';
-    const attachShortBody: boolean = attachShortBodyParam === 'true';
-
-    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    const Bookmark = mongoose.model<any, any>('Bookmark');
-    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    const pageService = crowi.pageService;
-    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    const pageGrantService: IPageGrantService = crowi.pageGrantService!;
-
-    try {
-      const pages = pageIds != null
-        ? await Page.findByIdsAndViewer(pageIds as string[], req.user, null, true)
-        : await Page.findByPathAndViewer(path as string, req.user, null, false, true);
-
-      const foundIds = pages.map(page => page._id);
-
-      let shortBodiesMap;
-      if (attachShortBody) {
-        shortBodiesMap = await pageService.shortBodiesMapByPageIds(foundIds, req.user);
-      }
+  router.get('/info',
+    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
+    validator.pageIdsOrPathRequired, validator.infoParams, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
+      const {
+        pageIds, path, attachBookmarkCount: attachBookmarkCountParam, attachShortBody: attachShortBodyParam,
+      } = req.query;
 
 
-      let bookmarkCountMap;
-      if (attachBookmarkCount) {
-        bookmarkCountMap = await Bookmark.getPageIdToCountMap(foundIds) as Record<string, number>;
-      }
+      const attachBookmarkCount: boolean = attachBookmarkCountParam === 'true';
+      const attachShortBody: boolean = attachShortBodyParam === 'true';
 
 
-      const idToPageInfoMap: Record<string, IPageInfo | IPageInfoForListing> = {};
+      const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const Bookmark = mongoose.model<any, any>('Bookmark');
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      const pageService = crowi.pageService;
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      const pageGrantService: IPageGrantService = crowi.pageGrantService!;
 
 
-      const isGuestUser = req.user == null;
+      try {
+        const pages = pageIds != null
+          ? await Page.findByIdsAndViewer(pageIds as string[], req.user, null, true)
+          : await Page.findByPathAndViewer(path as string, req.user, null, false, true);
 
 
-      const userRelatedGroups = await pageGrantService.getUserRelatedGroups(req.user);
+        const foundIds = pages.map(page => page._id);
 
 
-      for (const page of pages) {
+        let shortBodiesMap;
+        if (attachShortBody) {
+          shortBodiesMap = await pageService.shortBodiesMapByPageIds(foundIds, req.user);
+        }
+
+        let bookmarkCountMap;
+        if (attachBookmarkCount) {
+          bookmarkCountMap = await Bookmark.getPageIdToCountMap(foundIds) as Record<string, number>;
+        }
+
+        const idToPageInfoMap: Record<string, IPageInfo | IPageInfoForListing> = {};
+
+        const isGuestUser = req.user == null;
+
+        const userRelatedGroups = await pageGrantService.getUserRelatedGroups(req.user);
+
+        for (const page of pages) {
         // construct isIPageInfoForListing
         // construct isIPageInfoForListing
-        const basicPageInfo = pageService.constructBasicPageInfo(page, isGuestUser);
-
-        // TODO: use pageService.getCreatorIdForCanDelete to get creatorId (https://redmine.weseek.co.jp/issues/140574)
-        const canDeleteCompletely = pageService.canDeleteCompletely(
-          page,
-          page.creator == null ? null : getIdForRef(page.creator),
-          req.user,
-          false,
-          userRelatedGroups,
-        ); // use normal delete config
-
-        const pageInfo = (!isIPageInfoForEntity(basicPageInfo))
-          ? basicPageInfo
+          const basicPageInfo = pageService.constructBasicPageInfo(page, isGuestUser);
+
+          // TODO: use pageService.getCreatorIdForCanDelete to get creatorId (https://redmine.weseek.co.jp/issues/140574)
+          const canDeleteCompletely = pageService.canDeleteCompletely(
+            page,
+            page.creator == null ? null : getIdForRef(page.creator),
+            req.user,
+            false,
+            userRelatedGroups,
+          ); // use normal delete config
+
+          const pageInfo = (!isIPageInfoForEntity(basicPageInfo))
+            ? basicPageInfo
           // create IPageInfoForListing
           // create IPageInfoForListing
-          : {
-            ...basicPageInfo,
-            isAbleToDeleteCompletely: canDeleteCompletely,
-            bookmarkCount: bookmarkCountMap != null ? bookmarkCountMap[page._id.toString()] : undefined,
-            revisionShortBody: shortBodiesMap != null ? shortBodiesMap[page._id.toString()] : undefined,
-          } as IPageInfoForListing;
-
-        idToPageInfoMap[page._id.toString()] = pageInfo;
-      }
+            : {
+              ...basicPageInfo,
+              isAbleToDeleteCompletely: canDeleteCompletely,
+              bookmarkCount: bookmarkCountMap != null ? bookmarkCountMap[page._id.toString()] : undefined,
+              revisionShortBody: shortBodiesMap != null ? shortBodiesMap[page._id.toString()] : undefined,
+            } as IPageInfoForListing;
+
+          idToPageInfoMap[page._id.toString()] = pageInfo;
+        }
 
 
-      return res.apiv3(idToPageInfoMap);
-    }
-    catch (err) {
-      logger.error('Error occurred while fetching page informations.', err);
-      return res.apiv3Err(new ErrorV3('Error occurred while fetching page informations.'));
-    }
-  });
+        return res.apiv3(idToPageInfoMap);
+      }
+      catch (err) {
+        logger.error('Error occurred while fetching page informations.', err);
+        return res.apiv3Err(new ErrorV3('Error occurred while fetching page informations.'));
+      }
+    });
 
 
   return router;
   return router;
 };
 };

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

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

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

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

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

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

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

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

+ 208 - 197
apps/app/src/server/routes/apiv3/page/index.ts

@@ -16,6 +16,7 @@ import sanitize from 'sanitize-filename';
 
 
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import type { IPageGrantData } from '~/interfaces/page';
 import type { IPageGrantData } from '~/interfaces/page';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
@@ -170,61 +171,63 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/Page'
    *                  $ref: '#/components/schemas/Page'
    */
    */
-  router.get('/', certifySharedPage, accessTokenParser, loginRequired, validator.getPage, apiV3FormValidator, async(req, res) => {
-    const { user, isSharedPage } = req;
-    const {
-      pageId, path, findAll, revisionId, shareLinkId, includeEmpty,
-    } = req.query;
-
-    const isValid = (shareLinkId != null && pageId != null && path == null) || (shareLinkId == null && (pageId != null || path != null));
-    if (!isValid) {
-      return res.apiv3Err(new Error('Either parameter of (pageId or path) or (pageId and shareLinkId) is required.'), 400);
-    }
+  router.get('/',
+    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
+    certifySharedPage, loginRequired, validator.getPage, apiV3FormValidator, async(req, res) => {
+      const { user, isSharedPage } = req;
+      const {
+        pageId, path, findAll, revisionId, shareLinkId, includeEmpty,
+      } = req.query;
+
+      const isValid = (shareLinkId != null && pageId != null && path == null) || (shareLinkId == null && (pageId != null || path != null));
+      if (!isValid) {
+        return res.apiv3Err(new Error('Either parameter of (pageId or path) or (pageId and shareLinkId) is required.'), 400);
+      }
 
 
-    let page;
-    let pages;
-    try {
-      if (isSharedPage) {
-        const shareLink = await ShareLink.findOne({ _id: shareLinkId });
-        if (shareLink == null) {
-          throw new Error('ShareLink is not found');
+      let page;
+      let pages;
+      try {
+        if (isSharedPage) {
+          const shareLink = await ShareLink.findOne({ _id: { $eq: shareLinkId } });
+          if (shareLink == null) {
+            throw new Error('ShareLink is not found');
+          }
+          page = await Page.findOne({ _id: getIdForRef(shareLink.relatedPage) });
+        }
+        else if (pageId != null) { // prioritized
+          page = await Page.findByIdAndViewer(pageId, user);
+        }
+        else if (!findAll) {
+          page = await Page.findByPathAndViewer(path, user, null, true, false);
+        }
+        else {
+          pages = await Page.findByPathAndViewer(path, user, null, false, includeEmpty);
         }
         }
-        page = await Page.findOne({ _id: getIdForRef(shareLink.relatedPage) });
-      }
-      else if (pageId != null) { // prioritized
-        page = await Page.findByIdAndViewer(pageId, user);
-      }
-      else if (!findAll) {
-        page = await Page.findByPathAndViewer(path, user, null, true, false);
       }
       }
-      else {
-        pages = await Page.findByPathAndViewer(path, user, null, false, includeEmpty);
+      catch (err) {
+        logger.error('get-page-failed', err);
+        return res.apiv3Err(err, 500);
       }
       }
-    }
-    catch (err) {
-      logger.error('get-page-failed', err);
-      return res.apiv3Err(err, 500);
-    }
 
 
-    if (page == null && (pages == null || pages.length === 0)) {
-      return res.apiv3Err('Page is not found', 404);
-    }
+      if (page == null && (pages == null || pages.length === 0)) {
+        return res.apiv3Err('Page is not found', 404);
+      }
 
 
-    if (page != null) {
-      try {
-        page.initLatestRevisionField(revisionId);
+      if (page != null) {
+        try {
+          page.initLatestRevisionField(revisionId);
 
 
-        // populate
-        page = await page.populateDataToShowRevision();
-      }
-      catch (err) {
-        logger.error('populate-page-failed', err);
-        return res.apiv3Err(err, 500);
+          // populate
+          page = await page.populateDataToShowRevision();
+        }
+        catch (err) {
+          logger.error('populate-page-failed', err);
+          return res.apiv3Err(err, 500);
+        }
       }
       }
-    }
 
 
-    return res.apiv3({ page, pages });
-  });
+      return res.apiv3({ page, pages });
+    });
 
 
   router.get('/page-paths-with-descendant-count', getPagePathsWithDescendantCountFactory(crowi));
   router.get('/page-paths-with-descendant-count', getPagePathsWithDescendantCountFactory(crowi));
 
 
@@ -395,51 +398,52 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/Page'
    *                  $ref: '#/components/schemas/Page'
    */
    */
-  router.put('/likes', accessTokenParser, loginRequiredStrictly, addActivity, validator.likes, apiV3FormValidator, async(req, res) => {
-    const { pageId, bool: isLiked } = req.body;
+  router.put('/likes', accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }), loginRequiredStrictly, addActivity,
+    validator.likes, apiV3FormValidator, async(req, res) => {
+      const { pageId, bool: isLiked } = req.body;
 
 
-    let page;
-    try {
-      page = await Page.findByIdAndViewer(pageId, req.user);
-      if (page == null) {
-        return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
-      }
+      let page;
+      try {
+        page = await Page.findByIdAndViewer(pageId, req.user);
+        if (page == null) {
+          return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
+        }
 
 
-      if (isLiked) {
-        page = await page.like(req.user);
+        if (isLiked) {
+          page = await page.like(req.user);
+        }
+        else {
+          page = await page.unlike(req.user);
+        }
       }
       }
-      else {
-        page = await page.unlike(req.user);
+      catch (err) {
+        logger.error('update-like-failed', err);
+        return res.apiv3Err(err, 500);
       }
       }
-    }
-    catch (err) {
-      logger.error('update-like-failed', err);
-      return res.apiv3Err(err, 500);
-    }
 
 
-    const result = { page, seenUser: page.seenUsers };
+      const result = { page, seenUser: page.seenUsers };
 
 
-    const parameters = {
-      targetModel: SupportedTargetModel.MODEL_PAGE,
-      target: page,
-      action: isLiked ? SupportedAction.ACTION_PAGE_LIKE : SupportedAction.ACTION_PAGE_UNLIKE,
-    };
+      const parameters = {
+        targetModel: SupportedTargetModel.MODEL_PAGE,
+        target: page,
+        action: isLiked ? SupportedAction.ACTION_PAGE_LIKE : SupportedAction.ACTION_PAGE_UNLIKE,
+      };
 
 
-    activityEvent.emit('update', res.locals.activity._id, parameters, page, preNotifyService.generatePreNotify);
+      activityEvent.emit('update', res.locals.activity._id, parameters, page, preNotifyService.generatePreNotify);
 
 
 
 
-    res.apiv3({ result });
+      res.apiv3({ result });
 
 
-    if (isLiked) {
-      try {
+      if (isLiked) {
+        try {
         // global notification
         // global notification
-        await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_LIKE, page, req.user);
-      }
-      catch (err) {
-        logger.error('Like notification failed', err);
+          await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_LIKE, page, req.user);
+        }
+        catch (err) {
+          logger.error('Like notification failed', err);
+        }
       }
       }
-    }
-  });
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -466,7 +470,7 @@ module.exports = (crowi) => {
    *          500:
    *          500:
    *            description: Internal server error.
    *            description: Internal server error.
    */
    */
-  router.get('/info', certifySharedPage, loginRequired, validator.info, apiV3FormValidator, async(req, res) => {
+  router.get('/info', accessTokenParser([SCOPE.READ.FEATURES.PAGE]), certifySharedPage, loginRequired, validator.info, apiV3FormValidator, async(req, res) => {
     const { user, isSharedPage } = req;
     const { user, isSharedPage } = req;
     const { pageId } = req.query;
     const { pageId } = req.query;
 
 
@@ -515,74 +519,75 @@ module.exports = (crowi) => {
    *          500:
    *          500:
    *            description: Internal server error.
    *            description: Internal server error.
    */
    */
-  router.get('/grant-data', loginRequiredStrictly, validator.getGrantData, apiV3FormValidator, async(req, res) => {
-    const { pageId } = req.query;
+  router.get('/grant-data', accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequiredStrictly,
+    validator.getGrantData, apiV3FormValidator, async(req, res) => {
+      const { pageId } = req.query;
 
 
-    const Page = mongoose.model<IPage, PageModel>('Page');
-    const pageGrantService = crowi.pageGrantService as IPageGrantService;
+      const Page = mongoose.model<IPage, PageModel>('Page');
+      const pageGrantService = crowi.pageGrantService as IPageGrantService;
 
 
-    const page = await Page.findByIdAndViewer(pageId, req.user, null, false);
+      const page = await Page.findByIdAndViewer(pageId, req.user, null, false);
 
 
-    if (page == null) {
+      if (page == null) {
       // Empty page should not be related to grant API
       // Empty page should not be related to grant API
-      return res.apiv3Err(new ErrorV3('Page is unreachable or empty.', 'page_unreachable_or_empty'), 400);
-    }
-
-    const {
-      path, grant, grantedUsers, grantedGroups,
-    } = page;
-    let isGrantNormalized = false;
-    try {
-      const grantedUsersId = grantedUsers.map(ref => getIdForRef(ref));
-      isGrantNormalized = await pageGrantService.isGrantNormalized(req.user, path, grant, grantedUsersId, grantedGroups, false, false);
-    }
-    catch (err) {
-      logger.error('Error occurred while processing isGrantNormalized.', err);
-      return res.apiv3Err(err, 500);
-    }
+        return res.apiv3Err(new ErrorV3('Page is unreachable or empty.', 'page_unreachable_or_empty'), 400);
+      }
 
 
-    const currentPageGroupGrantData = await pageGrantService.getPageGroupGrantData(page, req.user);
-    const currentPageGrant: IPageGrantData = {
-      grant: page.grant,
-      groupGrantData: currentPageGroupGrantData,
-    };
+      const {
+        path, grant, grantedUsers, grantedGroups,
+      } = page;
+      let isGrantNormalized = false;
+      try {
+        const grantedUsersId = grantedUsers.map(ref => getIdForRef(ref));
+        isGrantNormalized = await pageGrantService.isGrantNormalized(req.user, path, grant, grantedUsersId, grantedGroups, false, false);
+      }
+      catch (err) {
+        logger.error('Error occurred while processing isGrantNormalized.', err);
+        return res.apiv3Err(err, 500);
+      }
 
 
-    // page doesn't have parent page
-    if (page.parent == null) {
-      const grantData = {
-        isForbidden: false,
-        currentPageGrant,
-        parentPageGrant: null,
+      const currentPageGroupGrantData = await pageGrantService.getPageGroupGrantData(page, req.user);
+      const currentPageGrant: IPageGrantData = {
+        grant: page.grant,
+        groupGrantData: currentPageGroupGrantData,
       };
       };
-      return res.apiv3({ isGrantNormalized, grantData });
-    }
 
 
-    const parentPage = await Page.findByIdAndViewer(getIdForRef(page.parent), req.user, null, false);
+      // page doesn't have parent page
+      if (page.parent == null) {
+        const grantData = {
+          isForbidden: false,
+          currentPageGrant,
+          parentPageGrant: null,
+        };
+        return res.apiv3({ isGrantNormalized, grantData });
+      }
+
+      const parentPage = await Page.findByIdAndViewer(getIdForRef(page.parent), req.user, null, false);
+
+      // user isn't allowed to see parent's grant
+      if (parentPage == null) {
+        const grantData = {
+          isForbidden: true,
+          currentPageGrant,
+          parentPageGrant: null,
+        };
+        return res.apiv3({ isGrantNormalized, grantData });
+      }
+
+      const parentPageGroupGrantData = await pageGrantService.getPageGroupGrantData(parentPage, req.user);
+      const parentPageGrant: IPageGrantData = {
+        grant,
+        groupGrantData: parentPageGroupGrantData,
+      };
 
 
-    // user isn't allowed to see parent's grant
-    if (parentPage == null) {
       const grantData = {
       const grantData = {
-        isForbidden: true,
+        isForbidden: false,
         currentPageGrant,
         currentPageGrant,
-        parentPageGrant: null,
+        parentPageGrant,
       };
       };
-      return res.apiv3({ isGrantNormalized, grantData });
-    }
-
-    const parentPageGroupGrantData = await pageGrantService.getPageGroupGrantData(parentPage, req.user);
-    const parentPageGrant: IPageGrantData = {
-      grant,
-      groupGrantData: parentPageGroupGrantData,
-    };
 
 
-    const grantData = {
-      isForbidden: false,
-      currentPageGrant,
-      parentPageGrant,
-    };
-
-    return res.apiv3({ isGrantNormalized, grantData });
-  });
+      return res.apiv3({ isGrantNormalized, grantData });
+    });
 
 
   // Check if non user related groups are granted page access.
   // Check if non user related groups are granted page access.
   // If specified page does not exist, check the closest ancestor.
   // If specified page does not exist, check the closest ancestor.
@@ -617,7 +622,8 @@ module.exports = (crowi) => {
    *         500:
    *         500:
    *           description: Internal server error.
    *           description: Internal server error.
    */
    */
-  router.get('/non-user-related-groups-granted', loginRequiredStrictly, validator.nonUserRelatedGroupsGranted, apiV3FormValidator,
+  router.get('/non-user-related-groups-granted', accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequiredStrictly,
+    validator.nonUserRelatedGroupsGranted, apiV3FormValidator,
     async(req, res: ApiV3Response) => {
     async(req, res: ApiV3Response) => {
       const { user } = req;
       const { user } = req;
       const path = normalizePath(req.query.path);
       const path = normalizePath(req.query.path);
@@ -687,28 +693,29 @@ module.exports = (crowi) => {
    *         500:
    *         500:
    *           description: Internal server error.
    *           description: Internal server error.
    */
    */
-  router.get('/applicable-grant', loginRequiredStrictly, validator.applicableGrant, apiV3FormValidator, async(req, res) => {
-    const { pageId } = req.query;
+  router.get('/applicable-grant', accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequiredStrictly, validator.applicableGrant, apiV3FormValidator,
+    async(req, res) => {
+      const { pageId } = req.query;
 
 
-    const Page = crowi.model('Page');
-    const page = await Page.findByIdAndViewer(pageId, req.user, null);
+      const Page = crowi.model('Page');
+      const page = await Page.findByIdAndViewer(pageId, req.user, null);
 
 
-    if (page == null) {
+      if (page == null) {
       // Empty page should not be related to grant API
       // Empty page should not be related to grant API
-      return res.apiv3Err(new ErrorV3('Page is unreachable or empty.', 'page_unreachable_or_empty'), 400);
-    }
+        return res.apiv3Err(new ErrorV3('Page is unreachable or empty.', 'page_unreachable_or_empty'), 400);
+      }
 
 
-    let data;
-    try {
-      data = await crowi.pageGrantService.calcApplicableGrantData(page, req.user);
-    }
-    catch (err) {
-      logger.error('Error occurred while processing calcApplicableGrantData.', err);
-      return res.apiv3Err(err, 500);
-    }
+      let data;
+      try {
+        data = await crowi.pageGrantService.calcApplicableGrantData(page, req.user);
+      }
+      catch (err) {
+        logger.error('Error occurred while processing calcApplicableGrantData.', err);
+        return res.apiv3Err(err, 500);
+      }
 
 
-    return res.apiv3(data);
-  });
+      return res.apiv3(data);
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -747,32 +754,34 @@ module.exports = (crowi) => {
    *               schema:
    *               schema:
    *                 $ref: '#/components/schemas/Page'
    *                 $ref: '#/components/schemas/Page'
    */
    */
-  router.put('/:pageId/grant', loginRequiredStrictly, excludeReadOnlyUser, validator.updateGrant, apiV3FormValidator, async(req, res) => {
-    const { pageId } = req.params;
-    const { grant, userRelatedGrantedGroups } = req.body;
+  router.put('/:pageId/grant', accessTokenParser([SCOPE.WRITE.FEATURES.PAGE]), loginRequiredStrictly, excludeReadOnlyUser,
+    validator.updateGrant, apiV3FormValidator,
+    async(req, res) => {
+      const { pageId } = req.params;
+      const { grant, userRelatedGrantedGroups } = req.body;
 
 
-    const Page = crowi.model('Page');
+      const Page = crowi.model('Page');
 
 
-    const page = await Page.findByIdAndViewer(pageId, req.user, null, false);
+      const page = await Page.findByIdAndViewer(pageId, req.user, null, false);
 
 
-    if (page == null) {
+      if (page == null) {
       // Empty page should not be related to grant API
       // Empty page should not be related to grant API
-      return res.apiv3Err(new ErrorV3('Page is unreachable or empty.', 'page_unreachable_or_empty'), 400);
-    }
+        return res.apiv3Err(new ErrorV3('Page is unreachable or empty.', 'page_unreachable_or_empty'), 400);
+      }
 
 
-    let data;
-    try {
-      const shouldUseV4Process = false;
-      const grantData = { grant, userRelatedGrantedGroups };
-      data = await crowi.pageService.updateGrant(page, req.user, grantData, shouldUseV4Process);
-    }
-    catch (err) {
-      logger.error('Error occurred while processing calcApplicableGrantData.', err);
-      return res.apiv3Err(err, 500);
-    }
+      let data;
+      try {
+        const shouldUseV4Process = false;
+        const grantData = { grant, userRelatedGrantedGroups };
+        data = await crowi.pageService.updateGrant(page, req.user, grantData, shouldUseV4Process);
+      }
+      catch (err) {
+        logger.error('Error occurred while processing calcApplicableGrantData.', err);
+        return res.apiv3Err(err, 500);
+      }
 
 
-    return res.apiv3(data);
-  });
+      return res.apiv3(data);
+    });
 
 
   /**
   /**
   * @swagger
   * @swagger
@@ -794,7 +803,7 @@ module.exports = (crowi) => {
   *          200:
   *          200:
   *            description: Return page's markdown
   *            description: Return page's markdown
   */
   */
-  router.get('/export/:pageId', loginRequiredStrictly, validator.export, async(req, res) => {
+  router.get('/export/:pageId', accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequiredStrictly, validator.export, async(req, res) => {
     const pageId: string = req.params.pageId;
     const pageId: string = req.params.pageId;
     const format: 'md' | 'pdf' = req.query.format ?? 'md';
     const format: 'md' | 'pdf' = req.query.format ?? 'md';
     const revisionId: string | undefined = req.query.revisionId;
     const revisionId: string | undefined = req.query.revisionId;
@@ -930,7 +939,7 @@ module.exports = (crowi) => {
    *          500:
    *          500:
    *            description: Internal server error.
    *            description: Internal server error.
    */
    */
-  router.get('/exist-paths', loginRequired, validator.exist, apiV3FormValidator, async(req, res) => {
+  router.get('/exist-paths', accessTokenParser([SCOPE.READ.FEATURES.PAGE]), loginRequired, validator.exist, apiV3FormValidator, async(req, res) => {
     const { fromPath, toPath } = req.query;
     const { fromPath, toPath } = req.query;
 
 
     try {
     try {
@@ -983,31 +992,33 @@ module.exports = (crowi) => {
    *          500:
    *          500:
    *            description: Internal server error.
    *            description: Internal server error.
    */
    */
-  router.put('/subscribe', accessTokenParser, loginRequiredStrictly, addActivity, validator.subscribe, apiV3FormValidator, async(req, res) => {
-    const { pageId, status } = req.body;
-    const userId = req.user._id;
+  router.put('/subscribe', accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }), loginRequiredStrictly, addActivity,
+    validator.subscribe, apiV3FormValidator,
+    async(req, res) => {
+      const { pageId, status } = req.body;
+      const userId = req.user._id;
 
 
-    try {
-      const subscription = await Subscription.subscribeByPageId(userId, pageId, status);
+      try {
+        const subscription = await Subscription.subscribeByPageId(userId, pageId, status);
 
 
-      const parameters = {};
-      if (SubscriptionStatusType.SUBSCRIBE === status) {
-        Object.assign(parameters, { action: SupportedAction.ACTION_PAGE_SUBSCRIBE });
-      }
-      else if (SubscriptionStatusType.UNSUBSCRIBE === status) {
-        Object.assign(parameters, { action: SupportedAction.ACTION_PAGE_UNSUBSCRIBE });
+        const parameters = {};
+        if (SubscriptionStatusType.SUBSCRIBE === status) {
+          Object.assign(parameters, { action: SupportedAction.ACTION_PAGE_SUBSCRIBE });
+        }
+        else if (SubscriptionStatusType.UNSUBSCRIBE === status) {
+          Object.assign(parameters, { action: SupportedAction.ACTION_PAGE_UNSUBSCRIBE });
+        }
+        if ('action' in parameters) {
+          activityEvent.emit('update', res.locals.activity._id, parameters);
+        }
+
+        return res.apiv3({ subscription });
       }
       }
-      if ('action' in parameters) {
-        activityEvent.emit('update', res.locals.activity._id, parameters);
+      catch (err) {
+        logger.error('Failed to update subscribe status', err);
+        return res.apiv3Err(err, 500);
       }
       }
-
-      return res.apiv3({ subscription });
-    }
-    catch (err) {
-      logger.error('Failed to update subscribe status', err);
-      return res.apiv3Err(err, 500);
-    }
-  });
+    });
 
 
 
 
   /**
   /**
@@ -1043,7 +1054,7 @@ module.exports = (crowi) => {
    *                   page:
    *                   page:
    *                     $ref: '#/components/schemas/Page'
    *                     $ref: '#/components/schemas/Page'
    */
    */
-  router.put('/:pageId/content-width', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser,
+  router.put('/:pageId/content-width', accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }), loginRequiredStrictly, excludeReadOnlyUser,
     validator.contentWidth, apiV3FormValidator, async(req, res) => {
     validator.contentWidth, apiV3FormValidator, async(req, res) => {
       const { pageId } = req.params;
       const { pageId } = req.params;
       const { expandContentWidth } = req.body;
       const { expandContentWidth } = req.body;

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

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

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

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

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

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

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

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

+ 343 - 284
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 { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import PageTagRelation from '~/server/models/page-tag-relation';
 import PageTagRelation from '~/server/models/page-tag-relation';
@@ -127,69 +128,70 @@ module.exports = (crowi) => {
    *          200:
    *          200:
    *            description: Return pages recently updated
    *            description: Return pages recently updated
    */
    */
-  router.get('/recent', accessTokenParser, 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
+  router.get('/recent', accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
+    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
 
 
-    const hideRestrictedByOwner = configManager.getConfig('security:list-policy:hideRestrictedByOwner');
-    const hideRestrictedByGroup = configManager.getConfig('security:list-policy:hideRestrictedByGroup');
+      const hideRestrictedByOwner = configManager.getConfig('security:list-policy:hideRestrictedByOwner');
+      const hideRestrictedByGroup = configManager.getConfig('security:list-policy:hideRestrictedByGroup');
 
 
-    /**
+      /**
     * @type {import('~/server/models/page').FindRecentUpdatedPagesOption}
     * @type {import('~/server/models/page').FindRecentUpdatedPagesOption}
     */
     */
-    const queryOptions = {
-      offset,
-      limit,
-      includeWipPage,
-      includeTrashed: false,
-      isRegExpEscapedFromPath: true,
-      sort: 'updatedAt',
-      desc: -1,
-      hideRestrictedByOwner,
-      hideRestrictedByGroup,
-    };
-
-    try {
-      const result = await Page.findRecentUpdatedPages('/', req.user, queryOptions);
-      if (result.pages.length > limit) {
-        result.pages.pop();
-      }
+      const queryOptions = {
+        offset,
+        limit,
+        includeWipPage,
+        includeTrashed: false,
+        isRegExpEscapedFromPath: true,
+        sort: 'updatedAt',
+        desc: -1,
+        hideRestrictedByOwner,
+        hideRestrictedByGroup,
+      };
 
 
-      result.pages.forEach((page) => {
-        if (page.lastUpdateUser != null && page.lastUpdateUser instanceof User) {
-          page.lastUpdateUser = serializeUserSecurely(page.lastUpdateUser);
-        }
-      });
-
-      const ids = result.pages.map((page) => { return page._id });
-      const relations = await PageTagRelation.find({ relatedPage: { $in: ids } }).populate('relatedTag');
-
-      // { pageId: [{ tag }, ...] }
-      const relationsMap = new Map();
-      // increment relationsMap
-      relations.forEach((relation) => {
-        const pageId = relation.relatedPage.toString();
-        if (!relationsMap.has(pageId)) {
-          relationsMap.set(pageId, []);
-        }
-        if (relation.relatedTag != null) {
-          relationsMap.get(pageId).push(relation.relatedTag);
+      try {
+        const result = await Page.findRecentUpdatedPages('/', req.user, queryOptions);
+        if (result.pages.length > limit) {
+          result.pages.pop();
         }
         }
-      });
-      // add tags to each page
-      result.pages.forEach((page) => {
-        const pageId = page._id.toString();
-        page.tags = relationsMap.has(pageId) ? relationsMap.get(pageId) : [];
-      });
 
 
-      return res.apiv3(result);
-    }
-    catch (err) {
-      logger.error('Failed to get recent pages', err);
-      return res.apiv3Err(new ErrorV3('Failed to get recent pages', 'unknown'), 500);
-    }
-  });
+        result.pages.forEach((page) => {
+          if (page.lastUpdateUser != null && page.lastUpdateUser instanceof User) {
+            page.lastUpdateUser = serializeUserSecurely(page.lastUpdateUser);
+          }
+        });
+
+        const ids = result.pages.map((page) => { return page._id });
+        const relations = await PageTagRelation.find({ relatedPage: { $in: ids } }).populate('relatedTag');
+
+        // { pageId: [{ tag }, ...] }
+        const relationsMap = new Map();
+        // increment relationsMap
+        relations.forEach((relation) => {
+          const pageId = relation.relatedPage.toString();
+          if (!relationsMap.has(pageId)) {
+            relationsMap.set(pageId, []);
+          }
+          if (relation.relatedTag != null) {
+            relationsMap.get(pageId).push(relation.relatedTag);
+          }
+        });
+        // add tags to each page
+        result.pages.forEach((page) => {
+          const pageId = page._id.toString();
+          page.tags = relationsMap.has(pageId) ? relationsMap.get(pageId) : [];
+        });
+
+        return res.apiv3(result);
+      }
+      catch (err) {
+        logger.error('Failed to get recent pages', err);
+        return res.apiv3Err(new ErrorV3('Failed to get recent pages', 'unknown'), 500);
+      }
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -242,82 +244,90 @@ module.exports = (crowi) => {
    *          409:
    *          409:
    *            description: page path is already existed
    *            description: page path is already existed
    */
    */
-  router.put('/rename', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, validator.renamePage, apiV3FormValidator, async(req, res) => {
-    const { pageId, revisionId } = req.body;
+  router.put(
+    '/rename',
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }),
+    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,
-    };
+      const options = {
+        isRecursively: req.body.isRecursively,
+        createRedirectPage: req.body.isRenameRedirect,
+        updateMetadata: req.body.updateMetadata,
+      };
 
 
-    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
       // 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
       // 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
     * @swagger
@@ -342,7 +352,12 @@ module.exports = (crowi) => {
     *                schema:
     *                schema:
     *                  type: object
     *                  type: object
     */
     */
-  router.post('/resume-rename', accessTokenParser, loginRequiredStrictly, validator.resumeRenamePage, apiV3FormValidator,
+  router.post(
+    '/resume-rename',
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    validator.resumeRenamePage,
+    apiV3FormValidator,
     async(req, res) => {
     async(req, res) => {
 
 
       const { pageId } = req.body;
       const { pageId } = req.body;
@@ -371,7 +386,8 @@ module.exports = (crowi) => {
         return res.apiv3Err(err, 500);
         return res.apiv3Err(err, 500);
       }
       }
       return res.apiv3();
       return res.apiv3();
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -392,54 +408,62 @@ module.exports = (crowi) => {
    *                      items:
    *                      items:
    *                        $ref: '#/components/schemas/Page'
    *                        $ref: '#/components/schemas/Page'
    */
    */
-  router.delete('/empty-trash', accessTokenParser, loginRequired, excludeReadOnlyUser, addActivity, apiV3FormValidator, async(req, res) => {
-    const options = {};
+  router.delete(
+    '/empty-trash',
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }),
+    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 = [
   validator.displayList = [
     query('limit').if(value => value != null).isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
     query('limit').if(value => value != null).isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
@@ -497,41 +521,41 @@ module.exports = (crowi) => {
     *                              lastUpdateUser:
     *                              lastUpdateUser:
     *                                $ref: '#/components/schemas/User'
     *                                $ref: '#/components/schemas/User'
     */
     */
-  router.get('/list', accessTokenParser, loginRequired, validator.list, apiV3FormValidator, async(req, res) => {
-
-    const path = normalizePath(req.query.path ?? '/');
-    const limit = parseInt(req.query.limit ?? configManager.getConfig('customize:showPageLimitationS'));
-    const page = req.query.page || 1;
-    const offset = (page - 1) * limit;
+  router.get('/list', accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequired, validator.list, apiV3FormValidator, async(req, res) => {
 
 
-    let includeTrashed = false;
+      const path = normalizePath(req.query.path ?? '/');
+      const limit = parseInt(req.query.limit ?? configManager.getConfig('customize:showPageLimitationS'));
+      const page = req.query.page || 1;
+      const offset = (page - 1) * limit;
+      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
    * @swagger
@@ -573,7 +597,14 @@ module.exports = (crowi) => {
    *          500:
    *          500:
    *            description: Internal server error.
    *            description: Internal server error.
    */
    */
-  router.post('/duplicate', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity, validator.duplicatePage, apiV3FormValidator,
+  router.post(
+    '/duplicate',
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }),
+    loginRequiredStrictly,
+    excludeReadOnlyUser,
+    addActivity,
+    validator.duplicatePage,
+    apiV3FormValidator,
     async(req, res) => {
     async(req, res) => {
       const { pageId, isRecursively, onlyDuplicateUserRelatedResources } = req.body;
       const { pageId, isRecursively, onlyDuplicateUserRelatedResources } = req.body;
 
 
@@ -636,7 +667,8 @@ module.exports = (crowi) => {
       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(result);
       return res.apiv3(result);
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -669,21 +701,25 @@ module.exports = (crowi) => {
    *                      items:
    *                      items:
    *                        $ref: '#/components/schemas/Page'
    *                        $ref: '#/components/schemas/Page'
    */
    */
-  router.get('/subordinated-list', accessTokenParser, loginRequired, async(req, res) => {
-    const { path } = req.query;
-    const limit = parseInt(req.query.limit) || LIMIT_FOR_LIST;
-
-    try {
-      const pageData = await Page.findByPath(path, true);
-      const result = await Page.findManageableListWithDescendants(pageData, req.user, { limit });
+  router.get(
+    '/subordinated-list',
+    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
+    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
     * @swagger
@@ -728,59 +764,67 @@ module.exports = (crowi) => {
     *                      type: boolean
     *                      type: boolean
     *                      description: Whether pages were deleted completely
     *                      description: Whether pages were deleted completely
     */
     */
-  router.post('/delete', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, validator.deletePages, apiV3FormValidator, async(req, res) => {
-    const {
-      pageIdToRevisionIdMap, isCompletely, isRecursively, isAnyoneWithTheLink,
-    } = req.body;
+  router.post(
+    '/delete',
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }),
+    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
    * @swagger
@@ -807,27 +851,35 @@ module.exports = (crowi) => {
    *                  type: object
    *                  type: object
    *                  description: Empty object
    *                  description: Empty object
    */
    */
-  // eslint-disable-next-line max-len
-  router.post('/convert-pages-by-path', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, adminRequired, validator.convertPagesByPath, apiV3FormValidator, async(req, res) => {
-    const { convertPath } = req.body;
-
-    // Convert by path
-    const normalizedPath = normalizePath(convertPath);
-    try {
-      await crowi.pageService.normalizeParentByPath(normalizedPath, req.user);
-    }
-    catch (err) {
-      logger.error(err);
+  router.post(
+    '/convert-pages-by-path',
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }),
+    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
    * @swagger
@@ -858,34 +910,41 @@ module.exports = (crowi) => {
    *                  type: object
    *                  type: object
    *                  description: Empty object
    *                  description: Empty object
   */
   */
-  // eslint-disable-next-line max-len
-  router.post('/legacy-pages-migration', accessTokenParser, loginRequired, excludeReadOnlyUser, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
-    const { pageIds: _pageIds, isRecursively } = req.body;
+  router.post(
+    '/legacy-pages-migration',
+    accessTokenParser([SCOPE.WRITE.FEATURES.PAGE], { acceptLegacy: true }),
+    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
    * @swagger
@@ -908,7 +967,7 @@ module.exports = (crowi) => {
    *                      type: number
    *                      type: number
    *                      description: Number of pages that can be migrated
    *                      description: Number of pages that can be migrated
    */
    */
-  router.get('/v5-migration-status', accessTokenParser, loginRequired, async(req, res) => {
+  router.get('/v5-migration-status', accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }), loginRequired, async(req, res) => {
     try {
     try {
       const isV5Compatible = configManager.getConfig('app:isV5Compatible');
       const isV5Compatible = configManager.getConfig('app:isV5Compatible');
       const migratablePagesCount = req.user != null ? await crowi.pageService.countPagesCanNormalizeParentByUser(req.user) : null; // null check since not using loginRequiredStrictly
       const migratablePagesCount = req.user != null ? await crowi.pageService.countPagesCanNormalizeParentByUser(req.user) : null; // null check since not using loginRequiredStrictly

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

@@ -0,0 +1,65 @@
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import { query } from 'express-validator';
+
+import { SupportedAction } from '~/interfaces/activity';
+import { SCOPE } from '@growi/core/dist/interfaces';
+import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
+import { AccessToken } from '~/server/models/access-token';
+import loggerFactory from '~/utils/logger';
+
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+const logger = loggerFactory('growi:routes:apiv3:personal-setting:generate-access-tokens');
+
+type ReqQuery = {
+  tokenId: string,
+}
+
+type DeleteAccessTokenRequest = Request<undefined, ApiV3Response, undefined, ReqQuery>;
+
+type DeleteAccessTokenHandlersFactory = (crowi: Crowi) => RequestHandler[];
+
+const validator = [
+  query('tokenId')
+    .exists()
+    .withMessage('tokenId is required')
+    .isString()
+    .withMessage('tokenId must be a string'),
+];
+
+export const deleteAccessTokenHandlersFactory: DeleteAccessTokenHandlersFactory = (crowi) => {
+
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const addActivity = generateAddActivityMiddleware();
+  const activityEvent = crowi.event('activity');
+
+  return [
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]),
+    loginRequiredStrictly,
+    excludeReadOnlyUser,
+    addActivity,
+    validator,
+    apiV3FormValidator,
+    async(req: DeleteAccessTokenRequest, res: ApiV3Response) => {
+      const { query } = req;
+      const { tokenId } = query;
+
+      try {
+        await AccessToken.deleteTokenById(tokenId);
+
+        const parameters = { action: SupportedAction.ACTION_USER_ACCESS_TOKEN_DELETE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3({});
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3(err.toString(), 'delete-access-token-failed'));
+      }
+    }];
+};

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

@@ -0,0 +1,51 @@
+import type { IUserHasId } from '@growi/core/dist/interfaces';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+
+import { SupportedAction } from '~/interfaces/activity';
+import { SCOPE } from '@growi/core/dist/interfaces';
+import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
+import { AccessToken } from '~/server/models/access-token';
+import loggerFactory from '~/utils/logger';
+
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+const logger = loggerFactory('growi:routes:apiv3:personal-setting:generate-access-tokens');
+
+interface DeleteAllAccessTokensRequest extends Request<undefined, ApiV3Response, undefined> {
+  user: IUserHasId,
+}
+
+type DeleteAllAccessTokensHandlersFactory = (crowi: Crowi) => RequestHandler[];
+
+export const deleteAllAccessTokensHandlersFactory: DeleteAllAccessTokensHandlersFactory = (crowi) => {
+
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const addActivity = generateAddActivityMiddleware();
+  const activityEvent = crowi.event('activity');
+
+  return [
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]),
+    loginRequiredStrictly,
+    excludeReadOnlyUser,
+    addActivity,
+    async(req: DeleteAllAccessTokensRequest, res: ApiV3Response) => {
+      const { user } = req;
+
+      try {
+        await AccessToken.deleteAllTokensByUserId(user._id);
+
+        const parameters = { action: SupportedAction.ACTION_USER_ACCESS_TOKEN_DELETE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3({});
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3(err.toString(), 'delete-all-access-token-failed'));
+      }
+    }];
+};

+ 107 - 0
apps/app/src/server/routes/apiv3/personal-setting/generate-access-token.ts

@@ -0,0 +1,107 @@
+import type {
+  IUserHasId, Scope,
+} from '@growi/core/dist/interfaces';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import { body } from 'express-validator';
+
+import { SupportedAction } from '~/interfaces/activity';
+import type Crowi from '~/server/crowi';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
+import { AccessToken } from '~/server/models/access-token';
+import { isValidScope } from '~/server/util/scope-utils';
+import loggerFactory from '~/utils/logger';
+
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+const logger = loggerFactory('growi:routes:apiv3:personal-setting:generate-access-tokens');
+
+type ReqBody = {
+  expiredAt: Date,
+  description?: string,
+  scopes?: Scope[],
+}
+
+interface GenerateAccessTokenRequest extends Request<undefined, ApiV3Response, ReqBody> {
+  user: IUserHasId,
+}
+
+type GenerateAccessTokenHandlerFactory = (crowi: Crowi) => RequestHandler[];
+
+const validator = [
+  body('expiredAt')
+    .exists()
+    .withMessage('expiredAt is required')
+    .custom((value) => {
+      const expiredAt = new Date(value);
+      const now = new Date();
+
+      // Check if date is valid
+      if (Number.isNaN(expiredAt.getTime())) {
+        throw new Error('Invalid date format');
+      }
+
+      // Check if date is in the future
+      if (expiredAt < now) {
+        throw new Error('Expiration date must be in the future');
+      }
+
+      return true;
+    }),
+
+  body('description')
+    .optional()
+    .isString()
+    .withMessage('description must be a string')
+    .isLength({ max: 200 })
+    .withMessage('description must be less than or equal to 200 characters'),
+
+  body('scopes')
+    .optional()
+    .isArray()
+    .withMessage('scope must be an array')
+    .custom((scopes: Scope[]) => {
+      scopes.forEach((scope) => {
+        if (!isValidScope(scope)) {
+          throw new Error(`Invalid scope: ${scope}}`);
+        }
+      });
+      return true;
+    })
+    .withMessage('Invalid scope'),
+];
+
+export const generateAccessTokenHandlerFactory: GenerateAccessTokenHandlerFactory = (crowi) => {
+
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const activityEvent = crowi.event('activity');
+  const addActivity = generateAddActivityMiddleware();
+
+  return [
+    loginRequiredStrictly,
+    excludeReadOnlyUser,
+    addActivity,
+    validator,
+    apiV3FormValidator,
+    async(req: GenerateAccessTokenRequest, res: ApiV3Response) => {
+
+      const { user, body } = req;
+      const { expiredAt, description, scopes } = body;
+
+      try {
+        const tokenData = await AccessToken.generateToken(user._id, expiredAt, scopes, description);
+
+        const parameters = { action: SupportedAction.ACTION_USER_ACCESS_TOKEN_CREATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3(tokenData);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3(err.toString(), 'generate-access-token-failed'));
+      }
+    },
+  ];
+};

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

@@ -0,0 +1,46 @@
+import type { IUserHasId } from '@growi/core/dist/interfaces';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+
+import { SCOPE } from '@growi/core/dist/interfaces';
+import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
+import { AccessToken } from '~/server/models/access-token';
+import loggerFactory from '~/utils/logger';
+
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+const logger = loggerFactory('growi:routes:apiv3:personal-setting:get-access-tokens');
+
+interface GetAccessTokenRequest extends Request<undefined, ApiV3Response, undefined> {
+  user: IUserHasId,
+}
+
+type GetAccessTokenHandlerFactory = (crowi: Crowi) => RequestHandler[];
+
+export const getAccessTokenHandlerFactory: GetAccessTokenHandlerFactory = (crowi) => {
+
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const addActivity = generateAddActivityMiddleware();
+
+  return [
+    accessTokenParser([SCOPE.READ.USER_SETTINGS.API.ACCESS_TOKEN]),
+    loginRequiredStrictly,
+    excludeReadOnlyUser,
+    addActivity,
+    async(req: GetAccessTokenRequest, res: ApiV3Response) => {
+      const { user } = req;
+
+      try {
+        const accessTokens = await AccessToken.findTokenByUserId(user._id);
+        return res.apiv3({ accessTokens });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3(err.toString(), 'colud_not_get_access_token'));
+      }
+    },
+  ];
+};

+ 270 - 166
apps/app/src/server/routes/apiv3/personal-setting.js → apps/app/src/server/routes/apiv3/personal-setting/index.js

@@ -1,3 +1,4 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { body } from 'express-validator';
 import { body } from 'express-validator';
 
 
@@ -8,11 +9,16 @@ import { SupportedAction } from '~/interfaces/activity';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
-import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-import EditorSettings from '../../models/editor-settings';
-import ExternalAccount from '../../models/external-account';
-import InAppNotificationSettings from '../../models/in-app-notification-settings';
+import { generateAddActivityMiddleware } from '../../../middlewares/add-activity';
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import EditorSettings from '../../../models/editor-settings';
+import ExternalAccount from '../../../models/external-account';
+import InAppNotificationSettings from '../../../models/in-app-notification-settings';
+
+import { deleteAccessTokenHandlersFactory } from './delete-access-token';
+import { deleteAllAccessTokensHandlersFactory } from './delete-all-access-tokens';
+import { generateAccessTokenHandlerFactory } from './generate-access-token';
+import { getAccessTokenHandlerFactory } from './get-access-tokens';
 
 
 
 
 const logger = loggerFactory('growi:routes:apiv3:personal-setting');
 const logger = loggerFactory('growi:routes:apiv3:personal-setting');
@@ -70,7 +76,7 @@ const router = express.Router();
  */
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
 
   const { User } = crowi.models;
   const { User } = crowi.models;
@@ -144,7 +150,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: personal params
    *                      description: personal params
    */
    */
-  router.get('/', accessTokenParser, loginRequiredStrictly, async(req, res) => {
+  router.get('/', accessTokenParser([SCOPE.READ.USER_SETTINGS.INFO], { acceptLegacy: true }), loginRequiredStrictly, async(req, res) => {
     const { username } = req.user;
     const { username } = req.user;
     try {
     try {
       const user = await User.findUserByUsername(username);
       const user = await User.findUserByUsername(username);
@@ -185,7 +191,7 @@ module.exports = (crowi) => {
    *                      type: number
    *                      type: number
    *                      description: Minimum password length
    *                      description: Minimum password length
    */
    */
-  router.get('/is-password-set', accessTokenParser, loginRequiredStrictly, async(req, res) => {
+  router.get('/is-password-set', accessTokenParser([SCOPE.READ.USER_SETTINGS.PASSWORD], { acceptLegacy: true }), loginRequiredStrictly, async(req, res) => {
     const { username } = req.user;
     const { username } = req.user;
 
 
     try {
     try {
@@ -226,36 +232,38 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: personal params
    *                      description: personal params
    */
    */
-  router.put('/', accessTokenParser, loginRequiredStrictly, addActivity, validator.personal, apiV3FormValidator, async(req, res) => {
+  router.put('/',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.INFO], { acceptLegacy: true }), loginRequiredStrictly, addActivity, validator.personal, apiV3FormValidator,
+    async(req, res) => {
 
 
-    try {
-      const user = await User.findOne({ _id: req.user.id });
-      user.name = req.body.name;
-      user.email = req.body.email;
-      user.lang = req.body.lang;
-      user.isEmailPublished = req.body.isEmailPublished;
-      user.slackMemberId = req.body.slackMemberId;
-
-      const isUniqueEmail = await user.isUniqueEmail();
-
-      if (!isUniqueEmail) {
-        logger.error('email-is-not-unique');
-        return res.apiv3Err(new ErrorV3('The email is already in use', 'email-is-already-in-use'));
-      }
+      try {
+        const user = await User.findOne({ _id: req.user.id });
+        user.name = req.body.name;
+        user.email = req.body.email;
+        user.lang = req.body.lang;
+        user.isEmailPublished = req.body.isEmailPublished;
+        user.slackMemberId = req.body.slackMemberId;
 
 
-      const updatedUser = await user.save();
+        const isUniqueEmail = await user.isUniqueEmail();
 
 
-      const parameters = { action: SupportedAction.ACTION_USER_PERSONAL_SETTINGS_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
+        if (!isUniqueEmail) {
+          logger.error('email-is-not-unique');
+          return res.apiv3Err(new ErrorV3('The email is already in use', 'email-is-already-in-use'));
+        }
 
 
-      return res.apiv3({ updatedUser });
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err('update-personal-settings-failed');
-    }
+        const updatedUser = await user.save();
 
 
-  });
+        const parameters = { action: SupportedAction.ACTION_USER_PERSONAL_SETTINGS_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3({ updatedUser });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err('update-personal-settings-failed');
+      }
+
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -285,22 +293,24 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: user data
    *                      description: user data
    */
    */
-  router.put('/image-type', accessTokenParser, loginRequiredStrictly, addActivity, validator.imageType, apiV3FormValidator, async(req, res) => {
-    const { isGravatarEnabled } = req.body;
+  router.put('/image-type', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.INFO], { acceptLegacy: true }), loginRequiredStrictly, addActivity,
+    validator.imageType, apiV3FormValidator,
+    async(req, res) => {
+      const { isGravatarEnabled } = req.body;
 
 
-    try {
-      const userData = await req.user.updateIsGravatarEnabled(isGravatarEnabled);
+      try {
+        const userData = await req.user.updateIsGravatarEnabled(isGravatarEnabled);
 
 
-      const parameters = { action: SupportedAction.ACTION_USER_IMAGE_TYPE_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
+        const parameters = { action: SupportedAction.ACTION_USER_IMAGE_TYPE_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
-      return res.apiv3({ userData });
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err('update-personal-settings-failed');
-    }
-  });
+        return res.apiv3({ userData });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err('update-personal-settings-failed');
+      }
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -321,19 +331,20 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: array of external accounts
    *                      description: array of external accounts
    */
    */
-  router.get('/external-accounts', accessTokenParser, loginRequiredStrictly, async(req, res) => {
-    const userData = req.user;
+  router.get('/external-accounts',
+    accessTokenParser([SCOPE.READ.USER_SETTINGS.EXTERNAL_ACCOUNT], { acceptLegacy: true }), loginRequiredStrictly, async(req, res) => {
+      const userData = req.user;
 
 
-    try {
-      const externalAccounts = await ExternalAccount.find({ user: userData });
-      return res.apiv3({ externalAccounts });
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err('get-external-accounts-failed');
-    }
+      try {
+        const externalAccounts = await ExternalAccount.find({ user: userData });
+        return res.apiv3({ externalAccounts });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err('get-external-accounts-failed');
+      }
 
 
-  });
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -365,27 +376,29 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: user data updated
    *                      description: user data updated
    */
    */
-  router.put('/password', accessTokenParser, loginRequiredStrictly, addActivity, validator.password, apiV3FormValidator, async(req, res) => {
-    const { body, user } = req;
-    const { oldPassword, newPassword } = body;
-
-    if (user.isPasswordSet() && !user.isPasswordValid(oldPassword)) {
-      return res.apiv3Err('wrong-current-password', 400);
-    }
-    try {
-      const userData = await user.updatePassword(newPassword);
+  router.put('/password',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.PASSWORD], { acceptLegacy: true }), loginRequiredStrictly, addActivity, validator.password, apiV3FormValidator,
+    async(req, res) => {
+      const { body, user } = req;
+      const { oldPassword, newPassword } = body;
+
+      if (user.isPasswordSet() && !user.isPasswordValid(oldPassword)) {
+        return res.apiv3Err('wrong-current-password', 400);
+      }
+      try {
+        const userData = await user.updatePassword(newPassword);
 
 
-      const parameters = { action: SupportedAction.ACTION_USER_PASSWORD_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
+        const parameters = { action: SupportedAction.ACTION_USER_PASSWORD_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
-      return res.apiv3({ userData });
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err('update-password-failed');
-    }
+        return res.apiv3({ userData });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err('update-password-failed');
+      }
 
 
-  });
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -408,7 +421,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: user data
    *                      description: user data
    */
    */
-  router.put('/api-token', loginRequiredStrictly, addActivity, async(req, res) => {
+  router.put('/api-token', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.API_TOKEN]), loginRequiredStrictly, addActivity, async(req, res) => {
     const { user } = req;
     const { user } = req;
 
 
     try {
     try {
@@ -426,6 +439,91 @@ module.exports = (crowi) => {
 
 
   });
   });
 
 
+  /**
+   * @swagger
+   *   /personal-setting/access-token:
+   *     get:
+   *       tags: [GeneralSetting]
+   *       operationId: getAccessToken
+   *       summary: /personal-setting/access-token
+   *       description: Get access token
+   *       responses:
+   *         200:
+   *           description: succded to get access token
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 properties:
+   *                   accessTokens:
+   *                     type: object
+   *                     description: array of access tokens
+   */
+  router.get('/access-token', accessTokenParser([SCOPE.READ.USER_SETTINGS.API.ACCESS_TOKEN]), getAccessTokenHandlerFactory(crowi));
+
+  /**
+   * @swagger
+   *   /personal-setting/access-token:
+   *     post:
+   *       tags: [GeneralSetting]
+   *       operationId: generateccessToken
+   *       summary: /personal-setting/access-token
+   *       description: Generate access token
+   *       responses:
+   *         200:
+   *           description: succeded to create access token
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 properties:
+   *                   _id:
+   *                     type: string
+   *                     description: id of access token
+   *                   token:
+   *                     type: string
+   *                     description: access token
+   *                   expiredAt:
+   *                     type: string
+   *                     description: expired date
+   *                   description:
+   *                     type: string
+   *                     description: description of access token
+   *                   scope:
+   *                     type: array
+   *                     description: scope of access token
+   *                     items:
+   *                      type: string
+   */
+  router.post('/access-token', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]), generateAccessTokenHandlerFactory(crowi));
+
+  /**
+   * @swagger
+   *   /personal-setting/access-token/:
+   *     delete:
+   *       tags: [GeneralSetting]
+   *       operationId: deleteAccessToken
+   *       summary: /personal-setting/access-token
+   *       description: Delete access token
+   *       responses:
+   *         200:
+   *           description: succeded to delete access token
+   *
+   */
+  router.delete('/access-token', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]), deleteAccessTokenHandlersFactory(crowi));
+
+  /**
+   * @swagger
+   *   /personal-setting/access-token/all:
+   *     delete:
+   *       tags: [GeneralSetting]
+   *       operationId: deleteAllAccessToken
+   *       summary: /personal-setting/access-token/all
+   *       description: Delete all access tokens
+   *       responses:
+   *         200:
+   *           description: succeded to delete all access tokens
+   */
+  router.delete('/access-token/all', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.API.ACCESS_TOKEN]), deleteAllAccessTokensHandlersFactory(crowi));
+
   /**
   /**
    * @swagger
    * @swagger
    *
    *
@@ -454,31 +552,33 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: Ldap account associate to me
    *                      description: Ldap account associate to me
    */
    */
-  router.put('/associate-ldap', accessTokenParser, loginRequiredStrictly, addActivity, validator.associateLdap, apiV3FormValidator, async(req, res) => {
-    const { passportService } = crowi;
-    const { user, body } = req;
-    const { username } = body;
-
-    if (!passportService.isLdapStrategySetup) {
-      logger.error('LdapStrategy has not been set up');
-      return res.apiv3Err('associate-ldap-account-failed', 405);
-    }
+  router.put('/associate-ldap', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.EXTERNAL_ACCOUNT]), loginRequiredStrictly, addActivity,
+    validator.associateLdap, apiV3FormValidator,
+    async(req, res) => {
+      const { passportService } = crowi;
+      const { user, body } = req;
+      const { username } = body;
+
+      if (!passportService.isLdapStrategySetup) {
+        logger.error('LdapStrategy has not been set up');
+        return res.apiv3Err('associate-ldap-account-failed', 405);
+      }
 
 
-    try {
-      await passport.authenticate('ldapauth');
-      const associateUser = await ExternalAccount.associate('ldap', username, user);
+      try {
+        await passport.authenticate('ldapauth');
+        const associateUser = await ExternalAccount.associate('ldap', username, user);
 
 
-      const parameters = { action: SupportedAction.ACTION_USER_LDAP_ACCOUNT_ASSOCIATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
+        const parameters = { action: SupportedAction.ACTION_USER_LDAP_ACCOUNT_ASSOCIATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
-      return res.apiv3({ associateUser });
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err('associate-ldap-account-failed');
-    }
+        return res.apiv3({ associateUser });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err('associate-ldap-account-failed');
+      }
 
 
-  });
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -505,34 +605,35 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: Ldap account disassociate to me
    *                      description: Ldap account disassociate to me
    */
    */
-  // eslint-disable-next-line max-len
-  router.put('/disassociate-ldap', accessTokenParser, loginRequiredStrictly, addActivity, validator.disassociateLdap, apiV3FormValidator, async(req, res) => {
-    const { user, body } = req;
-    const { providerType, accountId } = body;
-
-    try {
-      const count = await ExternalAccount.count({ user });
-      // make sure password set or this user has two or more ExternalAccounts
-      if (user.password == null && count <= 1) {
+  router.put('/disassociate-ldap',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.EXTERNAL_ACCOUNT]), loginRequiredStrictly, addActivity, validator.disassociateLdap, apiV3FormValidator,
+    async(req, res) => {
+      const { user, body } = req;
+      const { providerType, accountId } = body;
+
+      try {
+        const count = await ExternalAccount.count({ user });
+        // make sure password set or this user has two or more ExternalAccounts
+        if (user.password == null && count <= 1) {
+          return res.apiv3Err('disassociate-ldap-account-failed');
+        }
+        const disassociateUser = await ExternalAccount.findOneAndRemove({
+          providerType: { $eq: providerType },
+          accountId: { $eq: accountId },
+          user,
+        });
+
+        const parameters = { action: SupportedAction.ACTION_USER_LDAP_ACCOUNT_DISCONNECT };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3({ disassociateUser });
+      }
+      catch (err) {
+        logger.error(err);
         return res.apiv3Err('disassociate-ldap-account-failed');
         return res.apiv3Err('disassociate-ldap-account-failed');
       }
       }
-      const disassociateUser = await ExternalAccount.findOneAndRemove({
-        providerType: { $eq: providerType },
-        accountId: { $eq: accountId },
-        user,
-      });
 
 
-      const parameters = { action: SupportedAction.ACTION_USER_LDAP_ACCOUNT_DISCONNECT };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
-
-      return res.apiv3({ disassociateUser });
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err('disassociate-ldap-account-failed');
-    }
-
-  });
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -565,34 +666,36 @@ module.exports = (crowi) => {
    *                  type: object
    *                  type: object
    *                  description: editor settings
    *                  description: editor settings
    */
    */
-  router.put('/editor-settings', accessTokenParser, loginRequiredStrictly, addActivity, validator.editorSettings, apiV3FormValidator, async(req, res) => {
-    const query = { userId: req.user.id };
-    const { body } = req;
+  router.put('/editor-settings', accessTokenParser([SCOPE.WRITE.USER_SETTINGS.OTHER]), loginRequiredStrictly,
+    addActivity, validator.editorSettings, apiV3FormValidator,
+    async(req, res) => {
+      const query = { userId: req.user.id };
+      const { body } = req;
 
 
-    const {
-      theme, keymapMode, styleActiveLine, autoFormatMarkdownTable,
-    } = body;
+      const {
+        theme, keymapMode, styleActiveLine, autoFormatMarkdownTable,
+      } = body;
 
 
-    const document = {
-      theme, keymapMode, styleActiveLine, autoFormatMarkdownTable,
-    };
+      const document = {
+        theme, keymapMode, styleActiveLine, autoFormatMarkdownTable,
+      };
 
 
-    // Insert if document does not exist, and return new values
-    // See: https://mongoosejs.com/docs/api.html#model_Model.findOneAndUpdate
-    const options = { upsert: true, new: true };
-    try {
-      const response = await EditorSettings.findOneAndUpdate(query, { $set: document }, options);
+      // Insert if document does not exist, and return new values
+      // See: https://mongoosejs.com/docs/api.html#model_Model.findOneAndUpdate
+      const options = { upsert: true, new: true };
+      try {
+        const response = await EditorSettings.findOneAndUpdate(query, { $set: document }, options);
 
 
-      const parameters = { action: SupportedAction.ACTION_USER_EDITOR_SETTINGS_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
+        const parameters = { action: SupportedAction.ACTION_USER_EDITOR_SETTINGS_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
-      return res.apiv3(response);
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err('updating-editor-settings-failed');
-    }
-  });
+        return res.apiv3(response);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err('updating-editor-settings-failed');
+      }
+    });
 
 
 
 
   /**
   /**
@@ -612,7 +715,7 @@ module.exports = (crowi) => {
    *                  type: object
    *                  type: object
    *                  description: editor settings
    *                  description: editor settings
    */
    */
-  router.get('/editor-settings', accessTokenParser, loginRequiredStrictly, async(req, res) => {
+  router.get('/editor-settings', accessTokenParser([SCOPE.READ.USER_SETTINGS.OTHER]), loginRequiredStrictly, async(req, res) => {
     try {
     try {
       const query = { userId: req.user.id };
       const query = { userId: req.user.id };
       const editorSettings = await EditorSettings.findOne(query) ?? new EditorSettings();
       const editorSettings = await EditorSettings.findOne(query) ?? new EditorSettings();
@@ -655,29 +758,30 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                 type: object
    *                 type: object
    */
    */
-  // eslint-disable-next-line max-len
-  router.put('/in-app-notification-settings', accessTokenParser, loginRequiredStrictly, addActivity, validator.inAppNotificationSettings, apiV3FormValidator, async(req, res) => {
-    const query = { userId: req.user.id };
-    const subscribeRules = req.body.subscribeRules;
+  router.put('/in-app-notification-settings',
+    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.IN_APP_NOTIFICATION]),
+    loginRequiredStrictly, addActivity, validator.inAppNotificationSettings, apiV3FormValidator, async(req, res) => {
+      const query = { userId: req.user.id };
+      const subscribeRules = req.body.subscribeRules;
 
 
-    if (subscribeRules == null) {
-      return res.apiv3Err('no-rules-found');
-    }
+      if (subscribeRules == null) {
+        return res.apiv3Err('no-rules-found');
+      }
 
 
-    const options = { upsert: true, new: true, runValidators: true };
-    try {
-      const response = await InAppNotificationSettings.findOneAndUpdate(query, { $set: { subscribeRules } }, options);
+      const options = { upsert: true, new: true, runValidators: true };
+      try {
+        const response = await InAppNotificationSettings.findOneAndUpdate(query, { $set: { subscribeRules } }, options);
 
 
-      const parameters = { action: SupportedAction.ACTION_USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
+        const parameters = { action: SupportedAction.ACTION_USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
-      return res.apiv3(response);
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err('updating-in-app-notification-settings-failed');
-    }
-  });
+        return res.apiv3(response);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err('updating-in-app-notification-settings-failed');
+      }
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -698,7 +802,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: InAppNotificationSettings
    *                      description: InAppNotificationSettings
    */
    */
-  router.get('/in-app-notification-settings', accessTokenParser, loginRequiredStrictly, async(req, res) => {
+  router.get('/in-app-notification-settings', accessTokenParser([SCOPE.READ.USER_SETTINGS.IN_APP_NOTIFICATION]), loginRequiredStrictly, async(req, res) => {
     const query = { userId: req.user.id };
     const query = { userId: req.user.id };
     try {
     try {
       const response = await InAppNotificationSettings.findOne(query);
       const response = await InAppNotificationSettings.findOne(query);

+ 87 - 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 express from 'express';
 import { connection } from 'mongoose';
 import { connection } from 'mongoose';
 
 
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { Revision } from '~/server/models/revision';
 import { Revision } from '~/server/models/revision';
 import { normalizeLatestRevisionIfBroken } from '~/server/service/revision/normalize-latest-revision-if-broken';
 import { normalizeLatestRevisionIfBroken } from '~/server/service/revision/normalize-latest-revision-if-broken';
@@ -134,74 +135,76 @@ module.exports = (crowi) => {
    *                    type: number
    *                    type: number
    *                    description: offset of the revisions
    *                    description: offset of the revisions
    */
    */
-  router.get('/list', certifySharedPage, accessTokenParser, loginRequired, validator.retrieveRevisions, apiV3FormValidator, async(req, res) => {
-    const pageId = req.query.pageId;
-    const limit = req.query.limit || await crowi.configManager.getConfig('customize:showPageLimitationS') || 10;
-    const { isSharedPage } = req;
-    const offset = req.query.offset || 0;
-
-    // check whether accessible
-    if (!isSharedPage && !(await Page.isAccessiblePageByViewer(pageId, req.user))) {
-      return res.apiv3Err(new ErrorV3('Current user is not accessible to this page.', 'forbidden-page'), 403);
-    }
-
-    // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
-    try {
-      await normalizeLatestRevisionIfBroken(pageId);
-    }
-    catch (err) {
-      logger.error('Error occurred in normalizing the latest revision');
-    }
-
-    try {
-      const page = await Page.findOne({ _id: pageId });
-
-      const appliedAt = await getAppliedAtOfTheMigrationFile();
-
-      const queryOpts = {
-        offset,
-        sort: { createdAt: -1 },
-        populate: 'author',
-        pagination: false,
-      };
+  router.get('/list',
+    certifySharedPage, accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }), 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
    * @swagger
@@ -233,32 +236,34 @@ module.exports = (crowi) => {
    *                    revision:
    *                    revision:
    *                      $ref: '#/components/schemas/Revision'
    *                      $ref: '#/components/schemas/Revision'
    */
    */
-  router.get('/:id', certifySharedPage, accessTokenParser, loginRequired, validator.retrieveRevisionById, apiV3FormValidator, async(req, res) => {
-    const revisionId = req.params.id;
-    const pageId = req.query.pageId;
-    const { isSharedPage } = req;
-
-    // check whether accessible
-    if (!isSharedPage && !(await Page.isAccessiblePageByViewer(pageId, req.user))) {
-      return res.apiv3Err(new ErrorV3('Current user is not accessible to this page.', 'forbidden-page'), 403);
-    }
+  router.get('/:id',
+    certifySharedPage, accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }), 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;
   return router;
 };
 };

+ 62 - 56
apps/app/src/server/routes/apiv3/search.js

@@ -1,3 +1,4 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 
 
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
@@ -7,7 +8,6 @@ import loggerFactory from '~/utils/logger';
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
-
 const logger = loggerFactory('growi:routes:apiv3:search'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:search'); // eslint-disable-line no-unused-vars
 
 
 const express = require('express');
 const express = require('express');
@@ -126,21 +126,23 @@ module.exports = (crowi) => {
    *                    description: Status of indices
    *                    description: Status of indices
    *                    $ref: '#/components/schemas/Indices'
    *                    $ref: '#/components/schemas/Indices'
    */
    */
-  router.get('/indices', noCache(), accessTokenParser, loginRequired, adminRequired, async(req, res) => {
-    const { searchService } = crowi;
-
-    if (!searchService.isConfigured) {
-      return res.apiv3Err(new ErrorV3('SearchService is not configured', 'search-service-unconfigured'), 503);
-    }
-
-    try {
-      const info = await searchService.getInfoForAdmin();
-      return res.status(200).send({ info });
-    }
-    catch (err) {
-      return res.apiv3Err(err, 503);
-    }
-  });
+  router.get('/indices',
+    noCache(), accessTokenParser([SCOPE.READ.ADMIN.FULL_TEXT_SEARCH], { acceptLegacy: true }), loginRequired, adminRequired, async(req, res) => {
+      const { searchService } = crowi;
+
+      if (!searchService.isConfigured) {
+        return res.apiv3Err(new ErrorV3('SearchService is not configured', 'search-service-unconfigured'), 503);
+      }
+
+      try {
+        const info = await searchService.getInfoForAdmin();
+        return res.status(200).send({ info });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err, 503);
+      }
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -154,24 +156,26 @@ module.exports = (crowi) => {
    *        200:
    *        200:
    *          description: Successfully connected
    *          description: Successfully connected
    */
    */
-  router.post('/connection', accessTokenParser, loginRequired, adminRequired, addActivity, async(req, res) => {
-    const { searchService } = crowi;
+  router.post('/connection',
+    accessTokenParser([SCOPE.WRITE.ADMIN.FULL_TEXT_SEARCH], { acceptLegacy: true }), loginRequired, adminRequired, addActivity, async(req, res) => {
+      const { searchService } = crowi;
 
 
-    if (!searchService.isConfigured) {
-      return res.apiv3Err(new ErrorV3('SearchService is not configured', 'search-service-unconfigured'));
-    }
+      if (!searchService.isConfigured) {
+        return res.apiv3Err(new ErrorV3('SearchService is not configured', 'search-service-unconfigured'));
+      }
 
 
-    try {
-      await searchService.reconnectClient();
+      try {
+        await searchService.reconnectClient();
 
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SEARCH_CONNECTION });
+        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SEARCH_CONNECTION });
 
 
-      return res.status(200).send();
-    }
-    catch (err) {
-      return res.apiv3Err(err, 503);
-    }
-  });
+        return res.status(200).send();
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err, 503);
+      }
+    });
 
 
   const validatorForPutIndices = [
   const validatorForPutIndices = [
     body('operation').isString().isIn(['rebuild', 'normalize']),
     body('operation').isString().isIn(['rebuild', 'normalize']),
@@ -208,42 +212,44 @@ module.exports = (crowi) => {
    *                    type: string
    *                    type: string
    *                    description: Operation is successfully processed, or requested
    *                    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], { acceptLegacy: true }), 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
           // 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
           // 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;
   return router;
 };
 };

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

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

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

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

+ 241 - 228
apps/app/src/server/routes/apiv3/slack-integration-settings.js

@@ -1,4 +1,4 @@
-import { ConfigSource } from '@growi/core/dist/interfaces';
+import { ConfigSource, SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import {
 import {
   SlackbotType, REQUEST_TIMEOUT_FOR_GTOP,
   SlackbotType, REQUEST_TIMEOUT_FOR_GTOP,
@@ -16,7 +16,6 @@ import loggerFactory from '~/utils/logger';
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
-
 const axios = require('axios');
 const axios = require('axios');
 const express = require('express');
 const express = require('express');
 const { body, param } = require('express-validator');
 const { body, param } = require('express-validator');
@@ -198,7 +197,7 @@ module.exports = (crowi) => {
    *                    errorCode:
    *                    errorCode:
    *                      type: string
    *                      type: string
    */
    */
-  router.get('/', accessTokenParser, loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.SLACK_INTEGRATION], { acceptLegacy: true }), loginRequiredStrictly, adminRequired, async(req, res) => {
 
 
     const { configManager, slackIntegrationService } = crowi;
     const { configManager, slackIntegrationService } = crowi;
     const currentBotType = configManager.getConfig('slackbot:currentBotType');
     const currentBotType = configManager.getConfig('slackbot:currentBotType');
@@ -332,25 +331,26 @@ module.exports = (crowi) => {
    *           200:
    *           200:
    *             description: Succeeded to put botType setting.
    *             description: Succeeded to put botType setting.
    */
    */
-  // eslint-disable-next-line max-len
-  router.put('/bot-type', accessTokenParser, loginRequiredStrictly, adminRequired, addActivity, validator.botType, apiV3FormValidator, async(req, res) => {
-    const { currentBotType } = req.body;
+  router.put('/bot-type',
+    accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION], { acceptLegacy: true }),
+    loginRequiredStrictly, adminRequired, addActivity, validator.botType, apiV3FormValidator, async(req, res) => {
+      const { currentBotType } = req.body;
 
 
-    if (currentBotType == null) {
-      return res.apiv3Err(new ErrorV3('The param \'currentBotType\' must be specified.', 'update-CustomBotSetting-failed'), 400);
-    }
+      if (currentBotType == null) {
+        return res.apiv3Err(new ErrorV3('The param \'currentBotType\' must be specified.', 'update-CustomBotSetting-failed'), 400);
+      }
 
 
-    try {
-      await handleBotTypeChanging(req, res, currentBotType);
+      try {
+        await handleBotTypeChanging(req, res, currentBotType);
 
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_BOT_TYPE_UPDATE });
-    }
-    catch (error) {
-      const msg = 'Error occured in updating Custom bot setting';
-      logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'update-CustomBotSetting-failed'), 500);
-    }
-  });
+        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_BOT_TYPE_UPDATE });
+      }
+      catch (error) {
+        const msg = 'Error occured in updating Custom bot setting';
+        logger.error('Error', error);
+        return res.apiv3Err(new ErrorV3(msg, 'update-CustomBotSetting-failed'), 500);
+      }
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -369,18 +369,20 @@ module.exports = (crowi) => {
    *           200:
    *           200:
    *             description: Succeeded to delete botType setting.
    *             description: Succeeded to delete botType setting.
    */
    */
-  router.delete('/bot-type', accessTokenParser, loginRequiredStrictly, adminRequired, addActivity, apiV3FormValidator, async(req, res) => {
-    try {
-      await handleBotTypeChanging(req, res, null);
+  router.delete('/bot-type',
+    accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION], { acceptLegacy: true }), loginRequiredStrictly, adminRequired, addActivity, apiV3FormValidator,
+    async(req, res) => {
+      try {
+        await handleBotTypeChanging(req, res, null);
 
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_BOT_TYPE_DELETE });
-    }
-    catch (error) {
-      const msg = 'Error occured in resetting all';
-      logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'resetting-all-failed'), 500);
-    }
-  });
+        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_BOT_TYPE_DELETE });
+      }
+      catch (error) {
+        const msg = 'Error occured in resetting all';
+        logger.error('Error', error);
+        return res.apiv3Err(new ErrorV3(msg, 'resetting-all-failed'), 500);
+      }
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -407,32 +409,33 @@ module.exports = (crowi) => {
    *           200:
    *           200:
    *             description: Succeeded to put CustomBotWithoutProxy setting.
    *             description: Succeeded to put CustomBotWithoutProxy setting.
    */
    */
-  router.put('/without-proxy/update-settings', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
-    const currentBotType = crowi.configManager.getConfig('slackbot:currentBotType');
-    if (currentBotType !== SlackbotType.CUSTOM_WITHOUT_PROXY) {
-      const msg = 'Not CustomBotWithoutProxy';
-      return res.apiv3Err(new ErrorV3(msg, 'not-customBotWithoutProxy'), 400);
-    }
+  router.put('/without-proxy/update-settings', accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, addActivity,
+    async(req, res) => {
+      const currentBotType = crowi.configManager.getConfig('slackbot:currentBotType');
+      if (currentBotType !== SlackbotType.CUSTOM_WITHOUT_PROXY) {
+        const msg = 'Not CustomBotWithoutProxy';
+        return res.apiv3Err(new ErrorV3(msg, 'not-customBotWithoutProxy'), 400);
+      }
 
 
-    const { slackSigningSecret, slackBotToken } = req.body;
-    const requestParams = {
-      'slackbot:withoutProxy:signingSecret': slackSigningSecret,
-      'slackbot:withoutProxy:botToken': slackBotToken,
-    };
-    try {
-      await updateSlackBotSettings(requestParams);
-      crowi.slackIntegrationService.publishUpdatedMessage();
+      const { slackSigningSecret, slackBotToken } = req.body;
+      const requestParams = {
+        'slackbot:withoutProxy:signingSecret': slackSigningSecret,
+        'slackbot:withoutProxy:botToken': slackBotToken,
+      };
+      try {
+        await updateSlackBotSettings(requestParams);
+        crowi.slackIntegrationService.publishUpdatedMessage();
 
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_WITHOUT_PROXY_SETTINGS_UPDATE });
+        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_WITHOUT_PROXY_SETTINGS_UPDATE });
 
 
-      return res.apiv3();
-    }
-    catch (error) {
-      const msg = 'Error occured in updating Custom bot setting';
-      logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'update-CustomBotSetting-failed'), 500);
-    }
-  });
+        return res.apiv3();
+      }
+      catch (error) {
+        const msg = 'Error occured in updating Custom bot setting';
+        logger.error('Error', error);
+        return res.apiv3Err(new ErrorV3(msg, 'update-CustomBotSetting-failed'), 500);
+      }
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -459,34 +462,35 @@ module.exports = (crowi) => {
    *           200:
    *           200:
    *             description: Succeeded to put CustomBotWithoutProxy permissions.
    *             description: Succeeded to put CustomBotWithoutProxy permissions.
    */
    */
-  // eslint-disable-next-line max-len
-  router.put('/without-proxy/update-permissions', loginRequiredStrictly, adminRequired, addActivity, validator.updatePermissionsWithoutProxy, async(req, res) => {
-    const currentBotType = crowi.configManager.getConfig('slackbot:currentBotType');
-    if (currentBotType !== SlackbotType.CUSTOM_WITHOUT_PROXY) {
-      const msg = 'Not CustomBotWithoutProxy';
-      return res.apiv3Err(new ErrorV3(msg, 'not-customBotWithoutProxy'), 400);
-    }
+  router.put('/without-proxy/update-permissions',
+    accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]),
+    loginRequiredStrictly, adminRequired, addActivity, validator.updatePermissionsWithoutProxy, async(req, res) => {
+      const currentBotType = crowi.configManager.getConfig('slackbot:currentBotType');
+      if (currentBotType !== SlackbotType.CUSTOM_WITHOUT_PROXY) {
+        const msg = 'Not CustomBotWithoutProxy';
+        return res.apiv3Err(new ErrorV3(msg, 'not-customBotWithoutProxy'), 400);
+      }
 
 
-    // TODO: look here 78978
-    const { commandPermission, eventActionsPermission } = req.body;
-    const params = {
-      'slackbot:withoutProxy:commandPermission': commandPermission,
-      'slackbot:withoutProxy:eventActionsPermission': eventActionsPermission,
-    };
-    try {
-      await updateSlackBotSettings(params);
-      crowi.slackIntegrationService.publishUpdatedMessage();
+      // TODO: look here 78978
+      const { commandPermission, eventActionsPermission } = req.body;
+      const params = {
+        'slackbot:withoutProxy:commandPermission': commandPermission,
+        'slackbot:withoutProxy:eventActionsPermission': eventActionsPermission,
+      };
+      try {
+        await updateSlackBotSettings(params);
+        crowi.slackIntegrationService.publishUpdatedMessage();
 
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_WITHOUT_PROXY_PERMISSION_UPDATE });
+        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_WITHOUT_PROXY_PERMISSION_UPDATE });
 
 
-      return res.apiv3();
-    }
-    catch (error) {
-      const msg = 'Error occured in updating command permission settigns';
-      logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'update-CustomBotSetting-failed'), 500);
-    }
-  });
+        return res.apiv3();
+      }
+      catch (error) {
+        const msg = 'Error occured in updating command permission settigns';
+        logger.error('Error', error);
+        return res.apiv3Err(new ErrorV3(msg, 'update-CustomBotSetting-failed'), 500);
+      }
+    });
 
 
 
 
   /**
   /**
@@ -520,42 +524,43 @@ module.exports = (crowi) => {
    *                    isPrimary:
    *                    isPrimary:
    *                      type: boolean
    *                      type: boolean
    */
    */
-  router.post('/slack-app-integrations', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
-    const SlackAppIntegrationRecordsNum = await SlackAppIntegration.countDocuments();
-    if (SlackAppIntegrationRecordsNum >= 10) {
-      const msg = 'Not be able to create more than 10 slack workspace integration settings';
-      logger.error('Error', msg);
-      return res.apiv3Err(new ErrorV3(msg, 'create-slackAppIntegeration-failed'), 500);
-    }
-
-    const count = await SlackAppIntegration.count();
-
-    const { tokenGtoP, tokenPtoG } = await SlackAppIntegration.generateUniqueAccessTokens();
-    try {
-      const initialSupportedCommandsForBroadcastUse = new Map(defaultSupportedCommandsNameForBroadcastUse.map(command => [command, true]));
-      const initialSupportedCommandsForSingleUse = new Map(defaultSupportedCommandsNameForSingleUse.map(command => [command, true]));
-      const initialPermissionsForSlackEventActions = new Map(defaultSupportedSlackEventActions.map(action => [action, true]));
-
-      const slackAppTokens = await SlackAppIntegration.create({
-        tokenGtoP,
-        tokenPtoG,
-        permissionsForBroadcastUseCommands: initialSupportedCommandsForBroadcastUse,
-        permissionsForSingleUseCommands: initialSupportedCommandsForSingleUse,
-        permissionsForSlackEvents: initialPermissionsForSlackEventActions,
-        isPrimary: count === 0,
-      });
+  router.post('/slack-app-integrations', accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, addActivity,
+    async(req, res) => {
+      const SlackAppIntegrationRecordsNum = await SlackAppIntegration.countDocuments();
+      if (SlackAppIntegrationRecordsNum >= 10) {
+        const msg = 'Not be able to create more than 10 slack workspace integration settings';
+        logger.error('Error', msg);
+        return res.apiv3Err(new ErrorV3(msg, 'create-slackAppIntegeration-failed'), 500);
+      }
 
 
-      const parameters = { action: SupportedAction.ACTION_ADMIN_SLACK_WORKSPACE_CREATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
+      const count = await SlackAppIntegration.count();
 
 
-      return res.apiv3(slackAppTokens, 200);
-    }
-    catch (error) {
-      const msg = 'Error occurred during creating slack integration settings procedure';
-      logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'creating-slack-integration-settings-procedure-failed'), 500);
-    }
-  });
+      const { tokenGtoP, tokenPtoG } = await SlackAppIntegration.generateUniqueAccessTokens();
+      try {
+        const initialSupportedCommandsForBroadcastUse = new Map(defaultSupportedCommandsNameForBroadcastUse.map(command => [command, true]));
+        const initialSupportedCommandsForSingleUse = new Map(defaultSupportedCommandsNameForSingleUse.map(command => [command, true]));
+        const initialPermissionsForSlackEventActions = new Map(defaultSupportedSlackEventActions.map(action => [action, true]));
+
+        const slackAppTokens = await SlackAppIntegration.create({
+          tokenGtoP,
+          tokenPtoG,
+          permissionsForBroadcastUseCommands: initialSupportedCommandsForBroadcastUse,
+          permissionsForSingleUseCommands: initialSupportedCommandsForSingleUse,
+          permissionsForSlackEvents: initialPermissionsForSlackEventActions,
+          isPrimary: count === 0,
+        });
+
+        const parameters = { action: SupportedAction.ACTION_ADMIN_SLACK_WORKSPACE_CREATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3(slackAppTokens, 200);
+      }
+      catch (error) {
+        const msg = 'Error occurred during creating slack integration settings procedure';
+        logger.error('Error', error);
+        return res.apiv3Err(new ErrorV3(msg, 'creating-slack-integration-settings-procedure-failed'), 500);
+      }
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -584,7 +589,8 @@ module.exports = (crowi) => {
    *                    response:
    *                    response:
    *                      type: object
    *                      type: object
    */
    */
-  router.delete('/slack-app-integrations/:id', loginRequiredStrictly, adminRequired, validator.deleteIntegration, apiV3FormValidator, addActivity,
+  router.delete('/slack-app-integrations/:id', accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired,
+    validator.deleteIntegration, apiV3FormValidator, addActivity,
     async(req, res) => {
     async(req, res) => {
       const { id } = req.params;
       const { id } = req.params;
 
 
@@ -633,26 +639,28 @@ module.exports = (crowi) => {
    *               schema:
    *               schema:
    *                 type: object
    *                 type: object
    */
    */
-  router.put('/proxy-uri', loginRequiredStrictly, adminRequired, addActivity, validator.proxyUri, apiV3FormValidator, async(req, res) => {
-    const { proxyUri } = req.body;
+  router.put('/proxy-uri', accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, addActivity,
+    validator.proxyUri, apiV3FormValidator,
+    async(req, res) => {
+      const { proxyUri } = req.body;
 
 
-    const requestParams = { 'slackbot:proxyUri': proxyUri };
+      const requestParams = { 'slackbot:proxyUri': proxyUri };
 
 
-    try {
-      await updateSlackBotSettings(requestParams);
-      crowi.slackIntegrationService.publishUpdatedMessage();
+      try {
+        await updateSlackBotSettings(requestParams);
+        crowi.slackIntegrationService.publishUpdatedMessage();
 
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_PROXY_URI_UPDATE });
+        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_PROXY_URI_UPDATE });
 
 
-      return res.apiv3({});
-    }
-    catch (error) {
-      const msg = 'Error occured in updating Custom bot setting';
-      logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'delete-SlackAppIntegration-failed'), 500);
-    }
+        return res.apiv3({});
+      }
+      catch (error) {
+        const msg = 'Error occured in updating Custom bot setting';
+        logger.error('Error', error);
+        return res.apiv3Err(new ErrorV3(msg, 'delete-SlackAppIntegration-failed'), 500);
+      }
 
 
-  });
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -674,39 +682,40 @@ module.exports = (crowi) => {
    *          200:
    *          200:
    *            description: Succeeded to make it primary
    *            description: Succeeded to make it primary
    */
    */
-  // eslint-disable-next-line max-len
-  router.put('/slack-app-integrations/:id/make-primary', loginRequiredStrictly, adminRequired, addActivity, validator.makePrimary, apiV3FormValidator, async(req, res) => {
+  router.put('/slack-app-integrations/:id/make-primary',
+    accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]),
+    loginRequiredStrictly, adminRequired, addActivity, validator.makePrimary, apiV3FormValidator, async(req, res) => {
 
 
-    const { id } = req.params;
+      const { id } = req.params;
 
 
-    try {
-      await SlackAppIntegration.bulkWrite([
+      try {
+        await SlackAppIntegration.bulkWrite([
         // unset isPrimary for others
         // unset isPrimary for others
-        {
-          updateMany: {
-            filter: { _id: { $ne: id } },
-            update: { $unset: { isPrimary: '' } },
+          {
+            updateMany: {
+              filter: { _id: { $ne: id } },
+              update: { $unset: { isPrimary: '' } },
+            },
           },
           },
-        },
-        // set primary
-        {
-          updateOne: {
-            filter: { _id: id },
-            update: { isPrimary: true },
+          // set primary
+          {
+            updateOne: {
+              filter: { _id: id },
+              update: { isPrimary: true },
+            },
           },
           },
-        },
-      ]);
+        ]);
 
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_MAKE_APP_PRIMARY });
+        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_MAKE_APP_PRIMARY });
 
 
-      return res.apiv3();
-    }
-    catch (error) {
-      const msg = 'Error occurred during making SlackAppIntegration primary';
-      logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'making-primary-failed'), 500);
-    }
-  });
+        return res.apiv3();
+      }
+      catch (error) {
+        const msg = 'Error occurred during making SlackAppIntegration primary';
+        logger.error('Error', error);
+        return res.apiv3Err(new ErrorV3(msg, 'making-primary-failed'), 500);
+      }
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -732,25 +741,26 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  type: object
    *                  type: object
    */
    */
-  // eslint-disable-next-line max-len
-  router.put('/slack-app-integrations/:id/regenerate-tokens', loginRequiredStrictly, adminRequired, addActivity, validator.regenerateTokens, apiV3FormValidator, async(req, res) => {
+  router.put('/slack-app-integrations/:id/regenerate-tokens',
+    accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]),
+    loginRequiredStrictly, adminRequired, addActivity, validator.regenerateTokens, apiV3FormValidator, async(req, res) => {
 
 
-    const { id } = req.params;
+      const { id } = req.params;
 
 
-    try {
-      const { tokenGtoP, tokenPtoG } = await SlackAppIntegration.generateUniqueAccessTokens();
-      const slackAppTokens = await SlackAppIntegration.findByIdAndUpdate(id, { tokenGtoP, tokenPtoG });
+      try {
+        const { tokenGtoP, tokenPtoG } = await SlackAppIntegration.generateUniqueAccessTokens();
+        const slackAppTokens = await SlackAppIntegration.findByIdAndUpdate(id, { tokenGtoP, tokenPtoG });
 
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_ACCESS_TOKEN_REGENERATE });
+        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_ACCESS_TOKEN_REGENERATE });
 
 
-      return res.apiv3(slackAppTokens, 200);
-    }
-    catch (error) {
-      const msg = 'Error occurred during regenerating slack app tokens';
-      logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'regenerating-slackAppTokens-failed'), 500);
-    }
-  });
+        return res.apiv3(slackAppTokens, 200);
+      }
+      catch (error) {
+        const msg = 'Error occurred during regenerating slack app tokens';
+        logger.error('Error', error);
+        return res.apiv3Err(new ErrorV3(msg, 'regenerating-slackAppTokens-failed'), 500);
+      }
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -788,51 +798,52 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  type: object
    *                  type: object
    */
    */
-  // eslint-disable-next-line max-len
-  router.put('/slack-app-integrations/:id/permissions', loginRequiredStrictly, adminRequired, addActivity, validator.updatePermissionsWithProxy, apiV3FormValidator, async(req, res) => {
+  router.put('/slack-app-integrations/:id/permissions',
+    accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]),
+    loginRequiredStrictly, adminRequired, addActivity, validator.updatePermissionsWithProxy, apiV3FormValidator, async(req, res) => {
     // TODO: look here 78975
     // TODO: look here 78975
-    const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands, permissionsForSlackEventActions } = req.body;
-    const { id } = req.params;
-
-    const updatePermissionsForBroadcastUseCommands = new Map(Object.entries(permissionsForBroadcastUseCommands));
-    const updatePermissionsForSingleUseCommands = new Map(Object.entries(permissionsForSingleUseCommands));
-    const newPermissionsForSlackEventActions = new Map(Object.entries(permissionsForSlackEventActions));
+      const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands, permissionsForSlackEventActions } = req.body;
+      const { id } = req.params;
 
 
+      const updatePermissionsForBroadcastUseCommands = new Map(Object.entries(permissionsForBroadcastUseCommands));
+      const updatePermissionsForSingleUseCommands = new Map(Object.entries(permissionsForSingleUseCommands));
+      const newPermissionsForSlackEventActions = new Map(Object.entries(permissionsForSlackEventActions));
 
 
-    try {
-      const slackAppIntegration = await SlackAppIntegration.findByIdAndUpdate(
-        id,
-        {
-          permissionsForBroadcastUseCommands: updatePermissionsForBroadcastUseCommands,
-          permissionsForSingleUseCommands: updatePermissionsForSingleUseCommands,
-          permissionsForSlackEventActions: newPermissionsForSlackEventActions,
-        },
-        { new: true },
-      );
 
 
-      const proxyUri = crowi.slackIntegrationService.proxyUriForCurrentType;
-      if (proxyUri != null) {
-        await requestToProxyServer(
-          slackAppIntegration.tokenGtoP,
-          'put',
-          '/g2s/supported-commands',
+      try {
+        const slackAppIntegration = await SlackAppIntegration.findByIdAndUpdate(
+          id,
           {
           {
-            permissionsForBroadcastUseCommands: slackAppIntegration.permissionsForBroadcastUseCommands,
-            permissionsForSingleUseCommands: slackAppIntegration.permissionsForSingleUseCommands,
+            permissionsForBroadcastUseCommands: updatePermissionsForBroadcastUseCommands,
+            permissionsForSingleUseCommands: updatePermissionsForSingleUseCommands,
+            permissionsForSlackEventActions: newPermissionsForSlackEventActions,
           },
           },
+          { new: true },
         );
         );
-      }
 
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_PERMISSION_UPDATE });
+        const proxyUri = crowi.slackIntegrationService.proxyUriForCurrentType;
+        if (proxyUri != null) {
+          await requestToProxyServer(
+            slackAppIntegration.tokenGtoP,
+            'put',
+            '/g2s/supported-commands',
+            {
+              permissionsForBroadcastUseCommands: slackAppIntegration.permissionsForBroadcastUseCommands,
+              permissionsForSingleUseCommands: slackAppIntegration.permissionsForSingleUseCommands,
+            },
+          );
+        }
 
 
-      return res.apiv3({});
-    }
-    catch (error) {
-      const msg = `Error occured in updating settings. Cause: ${error.message}`;
-      logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'update-permissions-failed'), 500);
-    }
-  });
+        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_PERMISSION_UPDATE });
+
+        return res.apiv3({});
+      }
+      catch (error) {
+        const msg = `Error occured in updating settings. Cause: ${error.message}`;
+        logger.error('Error', error);
+        return res.apiv3Err(new ErrorV3(msg, 'update-permissions-failed'), 500);
+      }
+    });
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -863,7 +874,7 @@ module.exports = (crowi) => {
    *             description: Succeeded to delete botType setting.
    *             description: Succeeded to delete botType setting.
    */
    */
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
-  router.post('/slack-app-integrations/:id/relation-test', loginRequiredStrictly, adminRequired, addActivity, validator.relationTest, apiV3FormValidator, async(req, res) => {
+  router.post('/slack-app-integrations/:id/relation-test', accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, addActivity, validator.relationTest, apiV3FormValidator, async(req, res) => {
     const currentBotType = crowi.configManager.getConfig('slackbot:currentBotType');
     const currentBotType = crowi.configManager.getConfig('slackbot:currentBotType');
     if (currentBotType === SlackbotType.CUSTOM_WITHOUT_PROXY) {
     if (currentBotType === SlackbotType.CUSTOM_WITHOUT_PROXY) {
       const msg = 'Not Proxy Type';
       const msg = 'Not Proxy Type';
@@ -941,32 +952,34 @@ module.exports = (crowi) => {
    *           200:
    *           200:
    *             description: Succeeded to connect to slack work space.
    *             description: Succeeded to connect to slack work space.
    */
    */
-  router.post('/without-proxy/test', loginRequiredStrictly, adminRequired, addActivity, validator.slackChannel, apiV3FormValidator, async(req, res) => {
-    const currentBotType = crowi.configManager.getConfig('slackbot:currentBotType');
-    if (currentBotType !== SlackbotType.CUSTOM_WITHOUT_PROXY) {
-      const msg = 'Select Without Proxy Type';
-      return res.apiv3Err(new ErrorV3(msg, 'select-not-proxy-type'), 400);
-    }
+  router.post('/without-proxy/test', accessTokenParser([SCOPE.WRITE.ADMIN.SLACK_INTEGRATION]), loginRequiredStrictly, adminRequired, addActivity,
+    validator.slackChannel, apiV3FormValidator,
+    async(req, res) => {
+      const currentBotType = crowi.configManager.getConfig('slackbot:currentBotType');
+      if (currentBotType !== SlackbotType.CUSTOM_WITHOUT_PROXY) {
+        const msg = 'Select Without Proxy Type';
+        return res.apiv3Err(new ErrorV3(msg, 'select-not-proxy-type'), 400);
+      }
 
 
-    const slackBotToken = crowi.configManager.getConfig('slackbot:withoutProxy:botToken');
-    const status = await getConnectionStatus(slackBotToken);
-    if (status.error != null) {
-      return res.apiv3Err(new ErrorV3(`Error occured while getting connection. ${status.error}`, 'send-message-failed'));
-    }
+      const slackBotToken = crowi.configManager.getConfig('slackbot:withoutProxy:botToken');
+      const status = await getConnectionStatus(slackBotToken);
+      if (status.error != null) {
+        return res.apiv3Err(new ErrorV3(`Error occured while getting connection. ${status.error}`, 'send-message-failed'));
+      }
 
 
-    const { channel } = req.body;
-    const appSiteURL = crowi.configManager.getConfig('app:siteUrl');
-    try {
-      await sendSuccessMessage(slackBotToken, channel, appSiteURL);
-    }
-    catch (error) {
-      return res.apiv3Err(new ErrorV3(`Error occured while sending message. Cause: ${error.message}`, 'send-message-failed', error.stack));
-    }
+      const { channel } = req.body;
+      const appSiteURL = crowi.configManager.getConfig('app:siteUrl');
+      try {
+        await sendSuccessMessage(slackBotToken, channel, appSiteURL);
+      }
+      catch (error) {
+        return res.apiv3Err(new ErrorV3(`Error occured while sending message. Cause: ${error.message}`, 'send-message-failed', error.stack));
+      }
 
 
-    activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_WITHOUT_PROXY_TEST });
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_WITHOUT_PROXY_TEST });
 
 
-    return res.apiv3();
-  });
+      return res.apiv3();
+    });
 
 
   return router;
   return router;
 };
 };

Некоторые файлы не были показаны из-за большого количества измененных файлов