From a99cfa58280e5c0ca7a7f168e379bed09957e2ba Mon Sep 17 00:00:00 2001 From: Julio Araujo Date: Fri, 22 May 2026 16:18:24 +0200 Subject: [PATCH 001/161] fix: add TEST_MODE=true to OAuth ratelimiter's 'skip' (#40650) --- apps/meteor/server/configuration/configurePassport.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/meteor/server/configuration/configurePassport.ts b/apps/meteor/server/configuration/configurePassport.ts index 48c9ccd9d85cc..a7c5461a81e93 100644 --- a/apps/meteor/server/configuration/configurePassport.ts +++ b/apps/meteor/server/configuration/configurePassport.ts @@ -56,6 +56,7 @@ export const configurePassport = (settings: ICachedSettings) => { windowMs: settings.get('API_Enable_Rate_Limiter_Limit_Time_Default'), max: settings.get('API_Enable_Rate_Limiter_Limit_Calls_Default'), skip: () => + process.env.TEST_MODE === 'true' || settings.get('API_Enable_Rate_Limiter') !== true || (process.env.NODE_ENV === 'development' && settings.get('API_Enable_Rate_Limiter_Dev') !== true), handler: (_req, res) => { From 8cc6b352b8d48896ff2af134212111eb5a059218 Mon Sep 17 00:00:00 2001 From: Julio Araujo Date: Fri, 22 May 2026 17:33:37 +0200 Subject: [PATCH 002/161] chore(deps): bump protobufjs (#40639) --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index c8de9455d5486..5bae90e452368 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "markdown-it": "^14.1.1", "minimist": "1.2.6", "mongodb": "6.10.0", - "protobufjs": "7.5.6", + "protobufjs": "7.5.8", "webdav/axios": "0.31.1", "flatted@npm:^3.1.0": "3.4.2", "flatted@npm:^3.2.9": "3.4.2", diff --git a/yarn.lock b/yarn.lock index 05400ce25f121..ad7af9e6a2920 100644 --- a/yarn.lock +++ b/yarn.lock @@ -31878,9 +31878,9 @@ __metadata: languageName: node linkType: hard -"protobufjs@npm:7.5.6": - version: 7.5.6 - resolution: "protobufjs@npm:7.5.6" +"protobufjs@npm:7.5.8": + version: 7.5.8 + resolution: "protobufjs@npm:7.5.8" dependencies: "@protobufjs/aspromise": "npm:^1.1.2" "@protobufjs/base64": "npm:^1.1.2" @@ -31894,7 +31894,7 @@ __metadata: "@protobufjs/utf8": "npm:^1.1.1" "@types/node": "npm:>=13.7.0" long: "npm:^5.0.0" - checksum: 10/13d7ab5b5d588b704476a600506cda9f647e2f657faf5a18e8866ce5b04800026a508a4958673e4d5674090bbc528717b01128f8e226055d56e90745e3ee6ada + checksum: 10/2beb69b88a5c0a8420d0d5934f2358d417ecd6446f4c0d7c8a9ba5fc0e62ec1631938c0ab083258c14f15b87d39d559b49b398779638df57b2f3a3238cc0be1c languageName: node linkType: hard From fafb43d2b21b690f52cc5baad94367c6166b4266 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 12:34:27 -0300 Subject: [PATCH 003/161] chore(deps): bump actions/stale from 10.2.0 to 10.3.0 (#40637) --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index db848f3c5ac26..a4e06de4a534c 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -9,7 +9,7 @@ jobs: permissions: issues: write steps: - - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 + - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 with: days-before-issue-stale: 14 days-before-issue-close: 14 From effd5a6be773a9322628dbc4c2edac6037ad9afc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 12:34:49 -0300 Subject: [PATCH 004/161] chore(deps): bump rharkor/caching-for-turbo from 2.4.1 to 2.4.2 (#40596) --- .github/workflows/ci-code-check.yml | 2 +- .github/workflows/ci-deploy-gh-pages.yml | 2 +- .github/workflows/ci-test-e2e.yml | 2 +- .github/workflows/ci-test-storybook.yml | 2 +- .github/workflows/ci-test-unit.yml | 2 +- .github/workflows/ci.yml | 4 ++-- .github/workflows/new-release.yml | 2 +- .github/workflows/pr-update-description.yml | 2 +- .github/workflows/publish-release.yml | 2 +- .github/workflows/release-candidate.yml | 2 +- 10 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci-code-check.yml b/.github/workflows/ci-code-check.yml index 4416a9f8ada5a..21ff95c641948 100644 --- a/.github/workflows/ci-code-check.yml +++ b/.github/workflows/ci-code-check.yml @@ -41,7 +41,7 @@ jobs: install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - uses: rharkor/caching-for-turbo@2f61e9bebb2f51f2ed041ce70377b0f81b3b1e0d # v2.4.1 + - uses: rharkor/caching-for-turbo@5d14fba18e450c09393333cfd4242e8b3cb455a6 # v2.4.2 - uses: ./.github/actions/restore-packages diff --git a/.github/workflows/ci-deploy-gh-pages.yml b/.github/workflows/ci-deploy-gh-pages.yml index dc6b044dbd19b..e1b8b81517da6 100644 --- a/.github/workflows/ci-deploy-gh-pages.yml +++ b/.github/workflows/ci-deploy-gh-pages.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-24.04-arm steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: rharkor/caching-for-turbo@2f61e9bebb2f51f2ed041ce70377b0f81b3b1e0d # v2.4.1 + - uses: rharkor/caching-for-turbo@5d14fba18e450c09393333cfd4242e8b3cb455a6 # v2.4.2 - name: Setup NodeJS uses: ./.github/actions/setup-node diff --git a/.github/workflows/ci-test-e2e.yml b/.github/workflows/ci-test-e2e.yml index f90122800df9c..f35827b1b1a04 100644 --- a/.github/workflows/ci-test-e2e.yml +++ b/.github/workflows/ci-test-e2e.yml @@ -125,7 +125,7 @@ jobs: install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - uses: rharkor/caching-for-turbo@2f61e9bebb2f51f2ed041ce70377b0f81b3b1e0d # v2.4.1 + - uses: rharkor/caching-for-turbo@5d14fba18e450c09393333cfd4242e8b3cb455a6 # v2.4.2 - uses: ./.github/actions/restore-packages diff --git a/.github/workflows/ci-test-storybook.yml b/.github/workflows/ci-test-storybook.yml index 144b5895ffc18..9c7732f88a48d 100644 --- a/.github/workflows/ci-test-storybook.yml +++ b/.github/workflows/ci-test-storybook.yml @@ -37,7 +37,7 @@ jobs: install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - uses: rharkor/caching-for-turbo@2f61e9bebb2f51f2ed041ce70377b0f81b3b1e0d # v2.4.1 + - uses: rharkor/caching-for-turbo@5d14fba18e450c09393333cfd4242e8b3cb455a6 # v2.4.2 - uses: ./.github/actions/restore-packages diff --git a/.github/workflows/ci-test-unit.yml b/.github/workflows/ci-test-unit.yml index 13ea1f31d6490..a13e1fe4834cd 100644 --- a/.github/workflows/ci-test-unit.yml +++ b/.github/workflows/ci-test-unit.yml @@ -41,7 +41,7 @@ jobs: install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - uses: rharkor/caching-for-turbo@2f61e9bebb2f51f2ed041ce70377b0f81b3b1e0d # v2.4.1 + - uses: rharkor/caching-for-turbo@5d14fba18e450c09393333cfd4242e8b3cb455a6 # v2.4.2 - uses: ./.github/actions/restore-packages diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bf384fda3698..596ebed8c9002 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -246,7 +246,7 @@ jobs: restore-keys: | vite-local-cache-${{ runner.arch }}-${{ runner.os }}- - - uses: rharkor/caching-for-turbo@2f61e9bebb2f51f2ed041ce70377b0f81b3b1e0d # v2.4.1 + - uses: rharkor/caching-for-turbo@5d14fba18e450c09393333cfd4242e8b3cb455a6 # v2.4.2 if: steps.packages-cache-build.outputs.cache-hit != 'true' - name: Build Rocket.Chat Packages @@ -695,7 +695,7 @@ jobs: cache-modules: true install: true - - uses: rharkor/caching-for-turbo@2f61e9bebb2f51f2ed041ce70377b0f81b3b1e0d # v2.4.1 + - uses: rharkor/caching-for-turbo@5d14fba18e450c09393333cfd4242e8b3cb455a6 # v2.4.2 - name: Restore turbo build uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/new-release.yml b/.github/workflows/new-release.yml index 577a1453eb1c3..415e37e3c8f57 100644 --- a/.github/workflows/new-release.yml +++ b/.github/workflows/new-release.yml @@ -38,7 +38,7 @@ jobs: install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - uses: rharkor/caching-for-turbo@2f61e9bebb2f51f2ed041ce70377b0f81b3b1e0d # v2.4.1 + - uses: rharkor/caching-for-turbo@5d14fba18e450c09393333cfd4242e8b3cb455a6 # v2.4.2 - name: Build packages run: yarn build diff --git a/.github/workflows/pr-update-description.yml b/.github/workflows/pr-update-description.yml index 89e29fc3505a5..58fa2d371e213 100644 --- a/.github/workflows/pr-update-description.yml +++ b/.github/workflows/pr-update-description.yml @@ -25,7 +25,7 @@ jobs: install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - uses: rharkor/caching-for-turbo@2f61e9bebb2f51f2ed041ce70377b0f81b3b1e0d # v2.4.1 + - uses: rharkor/caching-for-turbo@5d14fba18e450c09393333cfd4242e8b3cb455a6 # v2.4.2 - name: Build packages run: yarn build diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 699e234554101..7be1cf06211f2 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -28,7 +28,7 @@ jobs: install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - uses: rharkor/caching-for-turbo@2f61e9bebb2f51f2ed041ce70377b0f81b3b1e0d # v2.4.1 + - uses: rharkor/caching-for-turbo@5d14fba18e450c09393333cfd4242e8b3cb455a6 # v2.4.2 - name: Build packages run: yarn build diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 376120c91d383..a956df9f1f018 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -19,7 +19,7 @@ jobs: install: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - uses: rharkor/caching-for-turbo@2f61e9bebb2f51f2ed041ce70377b0f81b3b1e0d # v2.4.1 + - uses: rharkor/caching-for-turbo@5d14fba18e450c09393333cfd4242e8b3cb455a6 # v2.4.2 - name: Build packages run: yarn build From 5d00ea57d3ff3cf40fad09510a7a0b32660992d0 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 22 May 2026 10:15:24 -0600 Subject: [PATCH 005/161] chore: Set `started` flag before calling `.start` on `NetworkBroker.start` (#40648) --- ee/packages/network-broker/src/NetworkBroker.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/ee/packages/network-broker/src/NetworkBroker.ts b/ee/packages/network-broker/src/NetworkBroker.ts index de4b603b15bf9..57f24f5c16e9b 100644 --- a/ee/packages/network-broker/src/NetworkBroker.ts +++ b/ee/packages/network-broker/src/NetworkBroker.ts @@ -191,8 +191,16 @@ export class NetworkBroker implements IBroker { } async start(): Promise { - await this.broker.start(); - - this.started = Promise.resolve(true); + try { + // We have to set this before calling `.start` as some services might call another service (settings, likely) as part of + // their `started` lifecycle method. `.call` bails out early (and silently) if `started` is false, returning undefined + // which can affect the service startup. + this.started = Promise.resolve(true); + + await this.broker.start(); + } catch (e) { + this.started = Promise.resolve(false); + throw e; + } } } From b6e8e78ef4a3c66a5997f53f77ecc320cd19cc17 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 13:17:42 -0300 Subject: [PATCH 006/161] chore(deps): bump github/codeql-action from 4.35.4 to 4.35.5 (#40597) --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index b6ccbed4870e1..d2034fd0f6436 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -28,7 +28,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 # Override language selection by uncommenting this and choosing your languages with: languages: javascript @@ -36,7 +36,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 # â„šī¸ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -50,4 +50,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 From 9379af47f821220ed4a18c92ac37388971396512 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 13:17:54 -0300 Subject: [PATCH 007/161] chore(deps): bump uuid and @actions/core in /.github/actions/update-version-durability (#40646) --- .../package-lock.json | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/.github/actions/update-version-durability/package-lock.json b/.github/actions/update-version-durability/package-lock.json index cd65c8d5e37b2..6434bbc5bf843 100644 --- a/.github/actions/update-version-durability/package-lock.json +++ b/.github/actions/update-version-durability/package-lock.json @@ -20,12 +20,22 @@ } }, "node_modules/@actions/core": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.10.1.tgz", - "integrity": "sha512-3lBR9EDAY+iYIpTnTIXmWcNbX3T2kCkAEQGIQx4NVQ0575nk2k3GRZDTPQG+vVtS2izSLmINlxXf0uLtnrTP+g==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz", + "integrity": "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==", + "license": "MIT", + "dependencies": { + "@actions/exec": "^1.1.1", + "@actions/http-client": "^2.0.1" + } + }, + "node_modules/@actions/exec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz", + "integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==", + "license": "MIT", "dependencies": { - "@actions/http-client": "^2.0.1", - "uuid": "^8.3.2" + "@actions/io": "^1.0.1" } }, "node_modules/@actions/http-client": { @@ -37,6 +47,12 @@ "undici": "^5.25.4" } }, + "node_modules/@actions/io": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz", + "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==", + "license": "MIT" + }, "node_modules/@fastify/busboy": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", @@ -562,14 +578,6 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.2.tgz", "integrity": "sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==" - }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "bin": { - "uuid": "dist/bin/uuid" - } } } } From bd62af903a8da3f5d7327244027a502806973d4d Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Mon, 25 May 2026 09:22:09 -0300 Subject: [PATCH 008/161] chore: update version to 8.6.0-develop --- apps/meteor/app/utils/rocketchat.info | 2 +- apps/meteor/package.json | 2 +- package.json | 2 +- packages/core-typings/package.json | 2 +- packages/rest-typings/package.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/meteor/app/utils/rocketchat.info b/apps/meteor/app/utils/rocketchat.info index 277ec8a3fd71f..0c23d7e2edd0b 100644 --- a/apps/meteor/app/utils/rocketchat.info +++ b/apps/meteor/app/utils/rocketchat.info @@ -1,3 +1,3 @@ { - "version": "8.5.0-develop" + "version": "8.6.0-develop" } diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 0751d9d0f615a..4426e80fa582f 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -1,6 +1,6 @@ { "name": "@rocket.chat/meteor", - "version": "8.5.0-develop", + "version": "8.6.0-develop", "private": true, "description": "The Ultimate Open Source WebChat Platform", "keywords": [ diff --git a/package.json b/package.json index 5bae90e452368..37b76ec7b0bfd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rocket.chat", - "version": "8.5.0-develop", + "version": "8.6.0-develop", "private": true, "description": "Rocket.Chat Monorepo", "homepage": "https://github.com/RocketChat/Rocket.Chat#readme", diff --git a/packages/core-typings/package.json b/packages/core-typings/package.json index 7bfb454b59f4d..fc809f6655081 100644 --- a/packages/core-typings/package.json +++ b/packages/core-typings/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package", "name": "@rocket.chat/core-typings", - "version": "8.5.0-develop", + "version": "8.6.0-develop", "main": "./dist/index.js", "typings": "./dist/index.d.ts", "files": [ diff --git a/packages/rest-typings/package.json b/packages/rest-typings/package.json index 7af85e25846d7..d4a3946c41f2b 100644 --- a/packages/rest-typings/package.json +++ b/packages/rest-typings/package.json @@ -1,6 +1,6 @@ { "name": "@rocket.chat/rest-typings", - "version": "8.5.0-develop", + "version": "8.6.0-develop", "main": "./dist/index.js", "typings": "./dist/index.d.ts", "files": [ From 3ef57058d8833f8fa6fae35f732491163cbcb94f Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 25 May 2026 14:49:31 +0000 Subject: [PATCH 009/161] fix: Skip `conditionalLockAgent` locking path for bot agents (#40635) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: KevLehman <11577696+KevLehman@users.noreply.github.com> Co-authored-by: Kevin Aleman --- .changeset/silent-coats-open.md | 5 +++++ .../livechat/server/lib/conditionalLockAgent.ts | 5 +++-- .../server/lib/conditionalLockAgent.spec.ts | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 .changeset/silent-coats-open.md diff --git a/.changeset/silent-coats-open.md b/.changeset/silent-coats-open.md new file mode 100644 index 0000000000000..e8e88a7966104 --- /dev/null +++ b/.changeset/silent-coats-open.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes the Chat Limits locking mechanism to allow bot agents to skip the lock as they aren't limited diff --git a/apps/meteor/app/livechat/server/lib/conditionalLockAgent.ts b/apps/meteor/app/livechat/server/lib/conditionalLockAgent.ts index 44f4d416c18a1..89935f3ec6470 100644 --- a/apps/meteor/app/livechat/server/lib/conditionalLockAgent.ts +++ b/apps/meteor/app/livechat/server/lib/conditionalLockAgent.ts @@ -1,5 +1,6 @@ import { Users } from '@rocket.chat/models'; +import { hasRoleAsync } from '../../../authorization/server/functions/hasRole'; import { settings } from '../../../settings/server'; type LockResult = { @@ -9,10 +10,10 @@ type LockResult = { }; export async function conditionalLockAgent(agentId: string): Promise { - // Lock and chats limits enforcement are only required when waiting_queue is enabled + // Lock and chats limits enforcement are only required when waiting_queue is enabled and the agent is not a bot const shouldLock = settings.get('Livechat_waiting_queue'); - if (!shouldLock) { + if (!shouldLock || (await hasRoleAsync(agentId, 'bot'))) { return { acquired: false, required: false, diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/conditionalLockAgent.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/conditionalLockAgent.spec.ts index 50c7677db7bf4..4b79d29aa2bb9 100644 --- a/apps/meteor/tests/unit/app/livechat/server/lib/conditionalLockAgent.spec.ts +++ b/apps/meteor/tests/unit/app/livechat/server/lib/conditionalLockAgent.spec.ts @@ -11,8 +11,11 @@ const mockSettings = { get: sinon.stub(), }; +const mockHasRoleAsync = sinon.stub(); + const { conditionalLockAgent } = proxyquire.noCallThru().load('../../../../../../app/livechat/server/lib/conditionalLockAgent', { '@rocket.chat/models': { Users: mockUsers }, + '../../../authorization/server/functions/hasRole': { hasRoleAsync: mockHasRoleAsync }, '../../../settings/server': { settings: mockSettings }, }); @@ -21,11 +24,13 @@ describe('conditionalLockAgent', () => { mockUsers.acquireAgentLock.reset(); mockUsers.releaseAgentLock.reset(); mockSettings.get.reset(); + mockHasRoleAsync.reset(); }); describe('when waiting_queue is enabled', () => { beforeEach(() => { mockSettings.get.withArgs('Livechat_waiting_queue').returns(true); + mockHasRoleAsync.resolves(false); }); it('should return acquired: true when lock is successfully acquired', async () => { @@ -59,6 +64,16 @@ describe('conditionalLockAgent', () => { expect(mockUsers.releaseAgentLock.firstCall.args[0]).to.equal('agent1'); expect(mockUsers.releaseAgentLock.firstCall.args[1]).to.be.instanceOf(Date); }); + + it('should skip lock acquisition for bot agents', async () => { + mockHasRoleAsync.resolves(true); + + const result = await conditionalLockAgent('agent1'); + + expect(result.acquired).to.equal(false); + expect(result.required).to.equal(false); + expect(mockUsers.acquireAgentLock.called).to.equal(false); + }); }); describe('when waiting_queue is disabled', () => { @@ -72,6 +87,7 @@ describe('conditionalLockAgent', () => { expect(result.acquired).to.equal(false); expect(result.required).to.equal(false); expect(mockUsers.acquireAgentLock.called).to.equal(false); + expect(mockHasRoleAsync.called).to.equal(false); }); it('should have a no-op unlock function', async () => { From 116c088c5d13dff6b821f93e9fffe9987c1ca7c8 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 25 May 2026 10:07:30 -0600 Subject: [PATCH 010/161] chore: Update browserlist & mongo-memory-server (#40660) --- .github/actions/setup-node/action.yml | 1 - ee/packages/abac/package.json | 2 +- yarn.lock | 47 ++++++++++++--------------- 3 files changed, 21 insertions(+), 29 deletions(-) diff --git a/.github/actions/setup-node/action.yml b/.github/actions/setup-node/action.yml index 875fb14bceb39..580340d6e0933 100644 --- a/.github/actions/setup-node/action.yml +++ b/.github/actions/setup-node/action.yml @@ -89,7 +89,6 @@ runs: shell: bash run: | CACHE_DIR="${{ runner.temp }}/mongodb-memory-server" - mkdir -p "$CACHE_DIR" echo "MONGOMS_DOWNLOAD_DIR=$CACHE_DIR" >> $GITHUB_ENV echo "MONGOMS_PREFER_GLOBAL_PATH=false" >> $GITHUB_ENV diff --git a/ee/packages/abac/package.json b/ee/packages/abac/package.json index 44d0db08a735b..bc675e5f103c5 100644 --- a/ee/packages/abac/package.json +++ b/ee/packages/abac/package.json @@ -43,7 +43,7 @@ "@types/node": "~22.19.17", "eslint": "~9.39.4", "jest": "~30.2.0", - "mongodb-memory-server": "^10.1.4", + "mongodb-memory-server": "^10.2.3", "typescript": "~5.9.3" } } diff --git a/yarn.lock b/yarn.lock index ad7af9e6a2920..f394a6b9f0f00 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8860,7 +8860,7 @@ __metadata: jest: "npm:~30.2.0" mem: "npm:^8.1.1" mongodb: "npm:6.10.0" - mongodb-memory-server: "npm:^10.1.4" + mongodb-memory-server: "npm:^10.2.3" p-limit: "npm:3.1.0" typescript: "npm:~5.9.3" languageName: unknown @@ -17578,11 +17578,11 @@ __metadata: linkType: hard "baseline-browser-mapping@npm:^2.10.12": - version: 2.10.18 - resolution: "baseline-browser-mapping@npm:2.10.18" + version: 2.10.32 + resolution: "baseline-browser-mapping@npm:2.10.32" bin: baseline-browser-mapping: dist/cli.cjs - checksum: 10/b0babff57a53539fc5f8b757dc224a2d1296ec7c91975373da70757041a03d797bc09a13820bfb4e6ce363cc1cf84b6b3649abc7fd912aa9104bc693bc714e50 + checksum: 10/65e8fa5ae614ed266d24e69aab517e46a7da5bbe16c46fa8373ae22007ab9f492c05d7308240e0a8b55bd0e899c67fd82a0e225f78ecb7e9164f23b0791f2784 languageName: node linkType: hard @@ -18273,17 +18273,10 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001109, caniuse-lite@npm:^1.0.30001718": - version: 1.0.30001755 - resolution: "caniuse-lite@npm:1.0.30001755" - checksum: 10/67f3b87bfd8f4da6fd69df185f54ab5409171f62185a52c916e1eb2f70f853aa374b0ce75d1742cc0215ca61e4bd1da8aa5557081bb2b6bb7220bf03a19b3b6e - languageName: node - linkType: hard - -"caniuse-lite@npm:^1.0.30001782": - version: 1.0.30001787 - resolution: "caniuse-lite@npm:1.0.30001787" - checksum: 10/2b3a2400b21d89123f13b7a8a7ad58dbdeb9971b62e33f43712d7209b69656f8bfd8ab67ab760c12e5fa7967b18c108ca67d7f20b7c7ccf039621f7db56baecf +"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001109, caniuse-lite@npm:^1.0.30001718, caniuse-lite@npm:^1.0.30001782": + version: 1.0.30001793 + resolution: "caniuse-lite@npm:1.0.30001793" + checksum: 10/5a1ac39f2f174e86d8320f394a5dfbeab98041722b13d02a21fe47afc2723bc754ea3716a1f276f8462bcbbc3016e82e6f155ebde0881e537af463b5eac1816b languageName: node linkType: hard @@ -28927,33 +28920,33 @@ __metadata: languageName: node linkType: hard -"mongodb-memory-server-core@npm:10.2.3": - version: 10.2.3 - resolution: "mongodb-memory-server-core@npm:10.2.3" +"mongodb-memory-server-core@npm:10.4.3": + version: 10.4.3 + resolution: "mongodb-memory-server-core@npm:10.4.3" dependencies: async-mutex: "npm:^0.5.0" camelcase: "npm:^6.3.0" - debug: "npm:^4.4.1" + debug: "npm:^4.4.3" find-cache-dir: "npm:^3.3.2" - follow-redirects: "npm:^1.15.9" + follow-redirects: "npm:^1.15.11" https-proxy-agent: "npm:^7.0.6" mongodb: "npm:^6.9.0" new-find-package-json: "npm:^2.0.0" - semver: "npm:^7.7.2" + semver: "npm:^7.7.3" tar-stream: "npm:^3.1.7" tslib: "npm:^2.8.1" yauzl: "npm:^3.2.0" - checksum: 10/eaf497167ed17fdbc464df5c26d8a9fa42e7acd3ca40c0646b6e0f27d92e31a37248191caadbefdc88c2b91ff7906465f11fd4c120e9e2c56805130723c00c24 + checksum: 10/23f918435779bd042d25959bee52519b20d7b59063c9d2304864690b9bd595575e4ab1432411144fb6f3e919821699f264ae471aba545739d2e7bed4ababd530 languageName: node linkType: hard -"mongodb-memory-server@npm:^10.1.4": - version: 10.2.3 - resolution: "mongodb-memory-server@npm:10.2.3" +"mongodb-memory-server@npm:^10.2.3": + version: 10.4.3 + resolution: "mongodb-memory-server@npm:10.4.3" dependencies: - mongodb-memory-server-core: "npm:10.2.3" + mongodb-memory-server-core: "npm:10.4.3" tslib: "npm:^2.8.1" - checksum: 10/72167e4ca7b9c188aeededa2f4920f7a92d9a18ff6dcc4f5549cefe6ec2eebccf045a72fc0ce3b0e529d055f2f6dc38cf59dca792c3d3a3c62c2ea1d81faf936 + checksum: 10/6c21bc36485233fa9d0fcd0b8f083c8f379f298822de89fff35d5c5a556db07d9cf85bc095990ad4454eb2b60db1c701198cb64550b4a1f52f25a0e8c4d55203 languageName: node linkType: hard From 731919a8e298e894f50c05a36fa5c253712ea855 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Mon, 25 May 2026 15:47:35 -0300 Subject: [PATCH 011/161] ci: TEST_MODE='api' to suppress deprecation throws in API tests (#40677) --- .github/workflows/ci-test-e2e.yml | 8 +++++++ apps/meteor/app/lib/server/lib/RateLimiter.js | 2 +- .../server/lib/deprecationWarningLogger.ts | 22 +++++++++++++------ .../app/livechat/server/api/v1/config.ts | 5 ++++- .../app/livechat/server/lib/AnalyticsTyped.ts | 4 ++-- apps/meteor/app/livechat/server/livechat.ts | 4 +++- apps/meteor/app/livechat/server/startup.ts | 2 +- .../app/settings/server/CachedSettings.ts | 2 +- apps/meteor/client/lib/queryClient.ts | 2 +- .../server/lib/AutoCloseOnHoldScheduler.ts | 2 +- .../server/lib/AutoTransferChatScheduler.ts | 2 +- .../server/lib/QueueInactivityMonitor.ts | 4 ++-- .../ee/server/apps/marketplace/isTesting.ts | 2 +- .../rocketchat-mongo-config/server/index.js | 2 +- .../server/configuration/configurePassport.ts | 1 + apps/meteor/server/methods/registerUser.ts | 2 +- .../server/services/authorization/service.ts | 2 +- .../providers/twilio.ts | 2 +- apps/meteor/server/startup/initialData.ts | 2 +- apps/meteor/server/startup/serverRunning.ts | 1 + docker-compose-ci.yml | 4 ++-- 21 files changed, 50 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci-test-e2e.yml b/.github/workflows/ci-test-e2e.yml index f35827b1b1a04..c8421db957b60 100644 --- a/.github/workflows/ci-test-e2e.yml +++ b/.github/workflows/ci-test-e2e.yml @@ -175,6 +175,13 @@ jobs: - name: Start containers for CE if: inputs.release == 'ce' + env: + # API tests intentionally invoke deprecated DDP methods through + # /v1/method.call/:method. TEST_MODE='api' keeps every other test + # behavior on (rate-limiter bypass, short cache TTLs) while letting + # the deprecation logger log without throwing. Other suites use the + # docker-compose default of TEST_MODE='true'. + TEST_MODE: ${{ (inputs.type == 'api' || inputs.type == 'api-livechat') && 'api' || 'true' }} run: | # when we are testing CE, we only need to start the rocketchat container DEBUG_LOG_LEVEL=${DEBUG_LOG_LEVEL:-0} docker compose -f docker-compose-ci.yml up -d rocketchat --wait @@ -185,6 +192,7 @@ jobs: ENTERPRISE_LICENSE: ${{ inputs.enterprise-license }} TRANSPORTER: ${{ inputs.transporter }} COMPOSE_PROFILES: ${{ inputs.type == 'api' && 'api' || '' }} + TEST_MODE: ${{ (inputs.type == 'api' || inputs.type == 'api-livechat') && 'api' || 'true' }} run: | DEBUG_LOG_LEVEL=${DEBUG_LOG_LEVEL:-0} docker compose -f docker-compose-ci.yml up -d --wait diff --git a/apps/meteor/app/lib/server/lib/RateLimiter.js b/apps/meteor/app/lib/server/lib/RateLimiter.js index cdb32299263d2..ac2b35bad3c97 100644 --- a/apps/meteor/app/lib/server/lib/RateLimiter.js +++ b/apps/meteor/app/lib/server/lib/RateLimiter.js @@ -2,7 +2,7 @@ import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; export const RateLimiterClass = new (class { limitMethod(methodName, numRequests, timeInterval, matchers) { - if (process.env.TEST_MODE === 'true') { + if (process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api') { return; } const match = { diff --git a/apps/meteor/app/lib/server/lib/deprecationWarningLogger.ts b/apps/meteor/app/lib/server/lib/deprecationWarningLogger.ts index 0d1bff3250a4c..9a1b46374c49a 100644 --- a/apps/meteor/app/lib/server/lib/deprecationWarningLogger.ts +++ b/apps/meteor/app/lib/server/lib/deprecationWarningLogger.ts @@ -26,12 +26,20 @@ const compareVersions = (version: string, message: string) => { export type DeprecationLoggerNextPlannedVersion = '9.0.0'; +// `TEST_MODE=true` makes the logger throw so accidental usage of a deprecated +// method/endpoint surfaces immediately in the test run. The API end-to-end +// suite sets `TEST_MODE=api` instead — every other test-mode behavior stays on +// (rate limiter bypass, short cache TTLs, etc.) but the deprecation logger +// only logs because the suite intentionally exercises deprecated DDP methods +// through `/v1/method.call/:method` and the streamer. +const shouldThrowOnDeprecation = process.env.TEST_MODE === 'true'; + export const apiDeprecationLogger = ((logger) => { return { endpoint: (endpoint: string, version: DeprecationLoggerNextPlannedVersion, res: Response, info = '') => { const message = `The endpoint "${endpoint}" is deprecated and will be removed on version ${version}${info ? ` (${info})` : ''}`; - if (process.env.TEST_MODE === 'true') { + if (shouldThrowOnDeprecation) { throw new Error(message); } @@ -59,7 +67,7 @@ export const apiDeprecationLogger = ((logger) => { }) ?? `The parameter "${parameter}" in the endpoint "${endpoint}" is deprecated and will be removed on version ${version}`; compareVersions(version, message); - if (process.env.TEST_MODE === 'true') { + if (shouldThrowOnDeprecation) { throw new Error(message); } @@ -87,7 +95,7 @@ export const apiDeprecationLogger = ((logger) => { version, }) ?? `The usage of the endpoint "${endpoint}" is deprecated and will be removed on version ${version}`; - if (process.env.TEST_MODE === 'true') { + if (shouldThrowOnDeprecation) { throw new Error(message); } @@ -114,7 +122,7 @@ export const methodDeprecationLogger = ((logger) => { const paths = infoArray.map((p) => (typeof p === 'string' && p.startsWith('/') ? `"${p}"` : p)).join(' or '); const replacement = infoArray.length > 0 ? `Use the ${paths} endpoint${infoArray.length > 1 ? 's' : ''} instead` : ''; const message = `The method "${method}" is deprecated and will be removed on version ${version}${replacement ? ` (${replacement})` : ''}`; - if (process.env.TEST_MODE === 'true') { + if (shouldThrowOnDeprecation) { throw new Error(message); } compareVersions(version, message); @@ -124,7 +132,7 @@ export const methodDeprecationLogger = ((logger) => { }, parameter: (method: string, parameter: string, version: DeprecationLoggerNextPlannedVersion) => { const message = `The parameter "${parameter}" in the method "${method}" is deprecated and will be removed on version ${version}`; - if (process.env.TEST_MODE === 'true') { + if (shouldThrowOnDeprecation) { throw new Error(message); } @@ -149,7 +157,7 @@ export const methodDeprecationLogger = ((logger) => { version, }) ?? `The usage of the method "${method}" is deprecated and will be removed on version ${version}`; - if (process.env.TEST_MODE === 'true') { + if (shouldThrowOnDeprecation) { throw new Error(message); } @@ -162,7 +170,7 @@ export const methodDeprecationLogger = ((logger) => { }, /** @deprecated */ warn: (message: string) => { - if (process.env.TEST_MODE === 'true') { + if (shouldThrowOnDeprecation) { throw new Error(message); } diff --git a/apps/meteor/app/livechat/server/api/v1/config.ts b/apps/meteor/app/livechat/server/api/v1/config.ts index 426f7c128882f..b7d8776bb6704 100644 --- a/apps/meteor/app/livechat/server/api/v1/config.ts +++ b/apps/meteor/app/livechat/server/api/v1/config.ts @@ -8,7 +8,10 @@ import { RoutingManager } from '../../lib/RoutingManager'; import { online } from '../../lib/service-status'; import { settings, findOpenRoom, getExtraConfigInfo, findAgent, findGuestWithoutActivity } from '../lib/livechat'; -const cachedSettings = mem(settings, { maxAge: process.env.TEST_MODE === 'true' ? 1 : 1000, cacheKey: JSON.stringify }); +const cachedSettings = mem(settings, { + maxAge: process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api' ? 1 : 1000, + cacheKey: JSON.stringify, +}); API.v1.addRoute( 'livechat/config', diff --git a/apps/meteor/app/livechat/server/lib/AnalyticsTyped.ts b/apps/meteor/app/livechat/server/lib/AnalyticsTyped.ts index c0be707ba2122..c1c6b7554e257 100644 --- a/apps/meteor/app/livechat/server/lib/AnalyticsTyped.ts +++ b/apps/meteor/app/livechat/server/lib/AnalyticsTyped.ts @@ -2,7 +2,7 @@ import { OmnichannelAnalytics } from '@rocket.chat/core-services'; import mem from 'mem'; export const getAgentOverviewDataCached = mem(OmnichannelAnalytics.getAgentOverviewData, { - maxAge: process.env.TEST_MODE === 'true' ? 1 : 60000, + maxAge: process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api' ? 1 : 60000, cacheKey: JSON.stringify, }); // Agent overview data on realtime is cached for 5 seconds @@ -12,6 +12,6 @@ export const getAnalyticsOverviewDataCached = mem(OmnichannelAnalytics.getAnalyt cacheKey: JSON.stringify, }); export const getAnalyticsOverviewDataCachedForRealtime = mem(OmnichannelAnalytics.getAnalyticsOverviewData, { - maxAge: process.env.TEST_MODE === 'true' ? 1 : 5000, + maxAge: process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api' ? 1 : 5000, cacheKey: JSON.stringify, }); diff --git a/apps/meteor/app/livechat/server/livechat.ts b/apps/meteor/app/livechat/server/livechat.ts index 3efc2feb17bd3..70dd67257650d 100644 --- a/apps/meteor/app/livechat/server/livechat.ts +++ b/apps/meteor/app/livechat/server/livechat.ts @@ -38,7 +38,9 @@ function parseExtraAttributes(widgetData: string): string { return doc.documentElement.innerHTML; } -const memoizedParseExtraAttributes = mem(parseExtraAttributes, { maxAge: process.env.TEST_MODE === 'true' ? 1 : 60000 }); +const memoizedParseExtraAttributes = mem(parseExtraAttributes, { + maxAge: process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api' ? 1 : 60000, +}); WebApp.connectHandlers.use('/livechat', (req, res, next) => { if (!req.url) { diff --git a/apps/meteor/app/livechat/server/startup.ts b/apps/meteor/app/livechat/server/startup.ts index 813644d5a51c9..1ba15a5e0baeb 100644 --- a/apps/meteor/app/livechat/server/startup.ts +++ b/apps/meteor/app/livechat/server/startup.ts @@ -120,7 +120,7 @@ Meteor.startup(async () => { } await businessHourManager.stopManager(); }, - process.env.TEST_MODE === 'true' + process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api' ? { debounce: 10, } diff --git a/apps/meteor/app/settings/server/CachedSettings.ts b/apps/meteor/app/settings/server/CachedSettings.ts index 8332f45c5c497..9a0c119182175 100644 --- a/apps/meteor/app/settings/server/CachedSettings.ts +++ b/apps/meteor/app/settings/server/CachedSettings.ts @@ -183,7 +183,7 @@ export class CachedSettings } const mergeFunction = - process.env.TEST_MODE !== 'true' + process.env.TEST_MODE !== 'true' && process.env.TEST_MODE !== 'api' ? _.debounce((): void => { callback(_id.map((id) => this.store.get(id)?.value) as T[]); }, 100) diff --git a/apps/meteor/client/lib/queryClient.ts b/apps/meteor/client/lib/queryClient.ts index f24351738f9c0..2d248da0c0d9f 100644 --- a/apps/meteor/client/lib/queryClient.ts +++ b/apps/meteor/client/lib/queryClient.ts @@ -4,7 +4,7 @@ export const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, - retry: process.env.TEST_MODE === 'true', + retry: process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api', }, mutations: { onError: console.warn, diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/AutoCloseOnHoldScheduler.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/AutoCloseOnHoldScheduler.ts index c8631f8e6165c..82a80e411120f 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/AutoCloseOnHoldScheduler.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/AutoCloseOnHoldScheduler.ts @@ -33,7 +33,7 @@ export class AutoCloseOnHoldSchedulerClass { mongo: (MongoInternals.defaultRemoteCollectionDriver().mongo as any).client.db(), db: { collection: SCHEDULER_NAME }, defaultConcurrency: 1, - processEvery: process.env.TEST_MODE === 'true' ? '3 seconds' : '1 minute', + processEvery: process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api' ? '3 seconds' : '1 minute', }); await this.scheduler.start(); diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/AutoTransferChatScheduler.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/AutoTransferChatScheduler.ts index 64fb8982bcbeb..c6c39c4512030 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/AutoTransferChatScheduler.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/AutoTransferChatScheduler.ts @@ -35,7 +35,7 @@ export class AutoTransferChatSchedulerClass { mongo: (MongoInternals.defaultRemoteCollectionDriver().mongo as any).client.db(), db: { collection: SCHEDULER_NAME }, defaultConcurrency: 1, - processEvery: process.env.TEST_MODE === 'true' ? '3 seconds' : '1 minute', + processEvery: process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api' ? '3 seconds' : '1 minute', }); await this.scheduler.start(); diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/QueueInactivityMonitor.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/QueueInactivityMonitor.ts index 2d1842da65fcf..5877f0ea8b01b 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/QueueInactivityMonitor.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/QueueInactivityMonitor.ts @@ -39,7 +39,7 @@ export class OmnichannelQueueInactivityMonitorClass { mongo: (MongoInternals.defaultRemoteCollectionDriver().mongo as any).client.db(), db: { collection: SCHEDULER_NAME }, defaultConcurrency: 1, - processEvery: process.env.TEST_MODE === 'true' ? '3 seconds' : '1 minute', + processEvery: process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api' ? '3 seconds' : '1 minute', }); this.createIndex(); const language = settings.get('Language') || 'en'; @@ -112,7 +112,7 @@ export class OmnichannelQueueInactivityMonitorClass { const { inquiryId } = data; // TODO: add projection and maybe use findOneQueued to avoid fetching the whole inquiry const inquiry = await LivechatInquiryRaw.findOneById(inquiryId); - if (!inquiry || inquiry.status !== 'queued') { + if (inquiry?.status !== 'queued') { return; } diff --git a/apps/meteor/ee/server/apps/marketplace/isTesting.ts b/apps/meteor/ee/server/apps/marketplace/isTesting.ts index dcc8347837072..ab28497f75f42 100644 --- a/apps/meteor/ee/server/apps/marketplace/isTesting.ts +++ b/apps/meteor/ee/server/apps/marketplace/isTesting.ts @@ -4,5 +4,5 @@ * @returns `true` if the `TEST_MODE` environment variable equals `'true'`, `false` otherwise. */ export function isTesting() { - return process.env.TEST_MODE === 'true'; + return process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api'; } diff --git a/apps/meteor/packages/rocketchat-mongo-config/server/index.js b/apps/meteor/packages/rocketchat-mongo-config/server/index.js index 721ae66e77a9d..ea22f833a68ad 100644 --- a/apps/meteor/packages/rocketchat-mongo-config/server/index.js +++ b/apps/meteor/packages/rocketchat-mongo-config/server/index.js @@ -40,7 +40,7 @@ if (Object.keys(mongoConnectionOptions).length > 0) { process.env.HTTP_FORWARDED_COUNT = process.env.HTTP_FORWARDED_COUNT || '1'; // Just print to logs if in TEST_MODE due to a bug in Meteor 2.5: TypeError: Cannot read property '_syncSendMail' of null -if (process.env.TEST_MODE === 'true') { +if ((process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api')) { Email.sendAsync = async function _sendAsync(options) { console.log('Email.sendAsync', options); }; diff --git a/apps/meteor/server/configuration/configurePassport.ts b/apps/meteor/server/configuration/configurePassport.ts index a7c5461a81e93..46adff48c78de 100644 --- a/apps/meteor/server/configuration/configurePassport.ts +++ b/apps/meteor/server/configuration/configurePassport.ts @@ -57,6 +57,7 @@ export const configurePassport = (settings: ICachedSettings) => { max: settings.get('API_Enable_Rate_Limiter_Limit_Calls_Default'), skip: () => process.env.TEST_MODE === 'true' || + process.env.TEST_MODE === 'api' || settings.get('API_Enable_Rate_Limiter') !== true || (process.env.NODE_ENV === 'development' && settings.get('API_Enable_Rate_Limiter_Dev') !== true), handler: (_req, res) => { diff --git a/apps/meteor/server/methods/registerUser.ts b/apps/meteor/server/methods/registerUser.ts index c664baaf4f582..44515f85ed175 100644 --- a/apps/meteor/server/methods/registerUser.ts +++ b/apps/meteor/server/methods/registerUser.ts @@ -150,7 +150,7 @@ let registerUserRuleId = RateLimiter.limitMethod( settings.watch('Rate_Limiter_Limit_RegisterUser', (value) => { // When running on testMode, there's no rate limiting added, so this function throws an error - if (process.env.TEST_MODE === 'true') { + if (process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api') { return; } diff --git a/apps/meteor/server/services/authorization/service.ts b/apps/meteor/server/services/authorization/service.ts index a8984dd79c241..1896abadc2cf1 100644 --- a/apps/meteor/server/services/authorization/service.ts +++ b/apps/meteor/server/services/authorization/service.ts @@ -21,7 +21,7 @@ export class Authorization extends ServiceClass implements IAuthorization { private rolesHasPermissionCached = mem(this.rolesHasPermission.bind(this), { cacheKey: JSON.stringify, - ...(process.env.TEST_MODE === 'true' && { maxAge: 1 }), + ...((process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api') && { maxAge: 1 }), }); constructor() { diff --git a/apps/meteor/server/services/omnichannel-integrations/providers/twilio.ts b/apps/meteor/server/services/omnichannel-integrations/providers/twilio.ts index 18e99f1ed0a85..e9eb3cb1ca469 100644 --- a/apps/meteor/server/services/omnichannel-integrations/providers/twilio.ts +++ b/apps/meteor/server/services/omnichannel-integrations/providers/twilio.ts @@ -272,7 +272,7 @@ export class Twilio implements ISMSProvider { async validateRequest(request: Request, requestBody: unknown): Promise { // We're not getting original twilio requests on CI :p - if (process.env.TEST_MODE === 'true') { + if (process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api') { return true; } const twilioHeader = request.headers.get('x-twilio-signature') || ''; diff --git a/apps/meteor/server/startup/initialData.ts b/apps/meteor/server/startup/initialData.ts index 2503f4d47bae1..b78430c79dcf0 100644 --- a/apps/meteor/server/startup/initialData.ts +++ b/apps/meteor/server/startup/initialData.ts @@ -205,7 +205,7 @@ Meteor.startup(async () => { await Users.removeById('rocketchat.internal.admin.test'); - if (process.env.TEST_MODE === 'true') { + if (process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api') { console.log(colors.green('Inserting admin test user:')); const adminUser: Omit = { diff --git a/apps/meteor/server/startup/serverRunning.ts b/apps/meteor/server/startup/serverRunning.ts index 90911769c20db..9408895221357 100644 --- a/apps/meteor/server/startup/serverRunning.ts +++ b/apps/meteor/server/startup/serverRunning.ts @@ -24,6 +24,7 @@ const exitIfNotBypassed = (ignore: string | undefined, errorCode = 1) => { const skipMongoDbDeprecationCheck = ['yes', 'true'].includes(String(process.env.SKIP_MONGODEPRECATION_CHECK).toLowerCase()) || process.env.TEST_MODE === 'true' || + process.env.TEST_MODE === 'api' || process.env.NODE_ENV === 'development'; // const skipMongoDbDeprecationBanner = ['yes', 'true'].includes(String(process.env.SKIP_MONGODEPRECATION_BANNER).toLowerCase()); diff --git a/docker-compose-ci.yml b/docker-compose-ci.yml index d0b3fd338d00e..305f0c81e2cc8 100644 --- a/docker-compose-ci.yml +++ b/docker-compose-ci.yml @@ -13,7 +13,7 @@ services: DENO_VERSION: ${DENO_VERSION:-} image: ghcr.io/${LOWERCASE_REPOSITORY}/rocket.chat:${DOCKER_TAG}${DOCKER_TAG_SUFFIX_ROCKETCHAT:-} environment: - - TEST_MODE=true + - 'TEST_MODE=${TEST_MODE:-true}' - DEBUG=${DEBUG:-} - EXIT_UNHANDLEDPROMISEREJECTION=true - MONGO_URL=mongodb://mongo:27017/rocketchat?replicaSet=rs0 @@ -162,7 +162,7 @@ services: SERVICE: omnichannel-transcript image: ghcr.io/${LOWERCASE_REPOSITORY}/omnichannel-transcript-service:${DOCKER_TAG} environment: - - TEST_MODE=true + - 'TEST_MODE=${TEST_MODE:-true}' - MONGO_URL=mongodb://mongo:27017/rocketchat?replicaSet=rs0 - 'TRANSPORTER=${TRANSPORTER:-}' - MOLECULER_LOG_LEVEL=info From 10266c1ea1b82f54140f1d84d543debe9aa752e8 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 25 May 2026 13:34:25 -0600 Subject: [PATCH 012/161] chore: Add missing abac error translations (#40683) --- packages/i18n/src/locales/en.i18n.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index e05c1718ea03b..99f7b4b067fb8 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -6267,11 +6267,17 @@ "email_style_description": "Avoid nested selectors", "email_style_label": "Email Style", "ensure_email_address_valid": "Invalid email address", + "error-abac-not-enabled": "Attribute-based access control is not enabled on this workspace.", + "error-abac-unsupported-object-type": "Attribute-based access control does not support this type of object.", + "error-abac-unsupported-operation": "This operation is not supported by attribute-based access control.", "error-action-not-allowed": "{{action}} is not allowed", "error-agent-offline": "Agent is offline", "error-agent-status-service-offline": "Agent status is offline or Omnichannel service is not active", "error-application-not-found": "Application not found", "error-archived-duplicate-name": "There's an archived channel with name '{{room_name}}'", + "error-attribute-definition-not-found": "The attribute definition could not be found.", + "error-attribute-in-use": "This attribute is currently assigned to one or more rooms and cannot be removed.", + "error-attribute-not-found": "The attribute could not be found.", "error-avatar-invalid-url": "Invalid avatar URL: {{url}}", "error-avatar-url-handling": "Error while handling avatar setting from a URL ({{url}}) for {{username}}", "error-blocked-username": "**{{field}}** is blocked and can't be used!", @@ -6279,6 +6285,7 @@ "error-business-hour-finish-time-equals-start-time": "Start and Finish time cannot be the same", "error-business-hours-are-closed": "Business Hours are closed", "error-canned-response-not-found": "Canned Response Not Found", + "error-cannot-convert-default-room-to-abac": "Default and team default rooms cannot be converted into attribute-based access control rooms.", "error-cannot-delete-app-user": "Deleting app user is not allowed, uninstall the corresponding app to remove it.", "error-cannot-place-chat-on-hold": "You cannot place chat on-hold", "error-cant-add-federated-users": "Can't add federated users to a non-federated room", @@ -6300,6 +6307,7 @@ "error-department-removal-disabled": "Department removal is disabled by administration, please contact your administrator", "error-direct-message-file-upload-not-allowed": "File sharing not allowed in direct messages", "error-direct-message-max-user-exceeded": "You cannot add more than {{maxUsers}} users, including yourself to a direct message", + "error-duplicate-attribute-key": "An attribute with this key already exists on this room.", "error-duplicate-channel-name": "A channel with name '{{channel_name}}' exists", "error-duplicate-priority-name": "A priority with the same name already exists", "error-duplicated-sla": "An SLA with the same name or due time already exists", @@ -6329,6 +6337,8 @@ "error-invalid-actionlink": "Invalid action link", "error-invalid-arguments": "Invalid arguments", "error-invalid-asset": "Invalid asset", + "error-invalid-attribute-key": "The provided attribute key is invalid.", + "error-invalid-attribute-values": "One or more of the provided attribute values are invalid.", "error-invalid-channel": "Invalid channel.", "error-invalid-channel-start-with-chars": "Invalid channel. Start with @ or #", "error-invalid-contact": "Invalid contact.", @@ -6406,6 +6416,7 @@ "error-password-policy-not-met-oneUppercase": "Password does not meet the server's policy of at least one uppercase character", "error-password-policy-not-met-repeatingCharacters": "Password not not meet the server's policy of forbidden repeating characters (you have too many of the same characters next to each other)", "error-password-same-as-current": "Entered password same as current password", + "error-pdp-unavailable": "The attribute-based access control policy decision service is currently unavailable. Please try again later.", "error-personal-access-tokens-are-current-disabled": "Personal Access Tokens are currently disabled", "error-pinning-message": "Message could not be pinned", "error-push-disabled": "Push is disabled", @@ -6420,6 +6431,7 @@ "error-room-does-not-exist": "This room does not exist", "error-room-is-already-on-hold": "Error! Room is already On Hold", "error-room-is-not-closed": "Room is not closed", + "error-room-not-found": "The requested room could not be found.", "error-room-not-on-hold": "Error! Room is not On Hold", "error-room-onHold": "Error! Room is On Hold", "error-room-is-abac-managed": "This room is ABAC managed and new users cannot be added", From af676c53156851c8418fa26a5dd9c7a641a5c6b7 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Mon, 25 May 2026 23:27:12 -0300 Subject: [PATCH 013/161] chore: migrate DDP callers to REST endpoints (used methods with REST replacement) (#40659) --- .../autotranslate/client/lib/autotranslate.ts | 6 ++++-- .../app/slashcommands-open/client/client.ts | 4 ++-- .../app/slashcommands-status/client/status.ts | 2 +- .../app/slashcommands-topic/client/topic.ts | 2 +- .../GameCenterInvitePlayersModal.tsx | 11 +++++----- .../message/toolbar/useTranslateAction.ts | 6 +++--- .../useViewOriginalTranslationAction.ts | 6 +++--- .../hooks/notification/useNotification.ts | 2 +- apps/meteor/client/lib/chats/data.ts | 12 ++++++----- .../client/lib/e2ee/rocketchat.e2e.room.ts | 12 ++++++----- .../lib/utils/mapCustomUserStatusFromApi.ts | 6 ++++++ .../client/lib/utils/mapReadReceiptFromApi.ts | 7 +++++++ .../UserMenu/hooks/useStatusItems.tsx | 21 ++++++++++++++++--- .../AccountTokensTable/AccountTokensTable.tsx | 8 +++---- .../tokens/AccountTokensTable/AddToken.tsx | 6 +++--- .../modals/RegisteredWorkspaceModal.tsx | 11 +++++----- .../hooks/useLoadSurroundingMessages.spec.ts | 4 ++-- .../room/composer/ComposerBoxPopupPreview.tsx | 17 ++++++++++----- .../Threads/hooks/useThreadMessagesQuery.ts | 18 ++++++++++------ .../client/views/room/hooks/useGoToRoom.ts | 9 +++++--- .../client/views/room/hooks/useOpenRoom.ts | 8 +++---- .../room/providers/ComposerPopupProvider.tsx | 12 +++++------ 22 files changed, 121 insertions(+), 69 deletions(-) create mode 100644 apps/meteor/client/lib/utils/mapCustomUserStatusFromApi.ts create mode 100644 apps/meteor/client/lib/utils/mapReadReceiptFromApi.ts diff --git a/apps/meteor/app/autotranslate/client/lib/autotranslate.ts b/apps/meteor/app/autotranslate/client/lib/autotranslate.ts index 9ab66e6086efc..bab1ed08213e1 100644 --- a/apps/meteor/app/autotranslate/client/lib/autotranslate.ts +++ b/apps/meteor/app/autotranslate/client/lib/autotranslate.ts @@ -85,10 +85,12 @@ export const AutoTranslate = { const loadProviders = async () => { try { - [this.providersMetadata, this.supportedLanguages] = await Promise.all([ + const [providersMetadata, supportedLanguagesResponse] = await Promise.all([ sdk.call('autoTranslate.getProviderUiMetadata'), - sdk.call('autoTranslate.getSupportedLanguages', 'en'), + sdk.rest.get('/v1/autotranslate.getSupportedLanguages', { targetLanguage: 'en' }), ]); + this.providersMetadata = providersMetadata; + this.supportedLanguages = supportedLanguagesResponse.languages; } catch (e: unknown) { // Avoid unwanted error message on UI when autotranslate is disabled while fetching data console.error((e as Error).message); diff --git a/apps/meteor/app/slashcommands-open/client/client.ts b/apps/meteor/app/slashcommands-open/client/client.ts index 88cee2fda319a..29354603ba716 100644 --- a/apps/meteor/app/slashcommands-open/client/client.ts +++ b/apps/meteor/app/slashcommands-open/client/client.ts @@ -27,11 +27,11 @@ slashCommands.add({ roomCoordinator.openRouteLink(subscription.t, subscription, router.getSearchParameters()); } - if (type && type.indexOf('d') === -1) { + if (type?.indexOf('d') === -1) { return; } try { - await sdk.call('createDirectMessage', room); + await sdk.rest.post('/v1/im.create', { username: room }); const subscription = Subscriptions.state.find(predicate); if (!subscription) { return; diff --git a/apps/meteor/app/slashcommands-status/client/status.ts b/apps/meteor/app/slashcommands-status/client/status.ts index 3698b5fda4cb5..04795d1c1d8b9 100644 --- a/apps/meteor/app/slashcommands-status/client/status.ts +++ b/apps/meteor/app/slashcommands-status/client/status.ts @@ -12,7 +12,7 @@ slashCommands.add({ } try { - await sdk.call('setUserStatus', undefined, params); + await sdk.rest.post('/v1/users.setStatus', { message: params }); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } diff --git a/apps/meteor/app/slashcommands-topic/client/topic.ts b/apps/meteor/app/slashcommands-topic/client/topic.ts index 697f87716a903..f28dddcafd9fb 100644 --- a/apps/meteor/app/slashcommands-topic/client/topic.ts +++ b/apps/meteor/app/slashcommands-topic/client/topic.ts @@ -12,7 +12,7 @@ slashCommands.add({ callback: async function Topic({ params, message }: SlashCommandCallbackParams<'topic'>): Promise { if (hasPermission('edit-room', message.rid)) { try { - await sdk.call('saveRoomSettings', message.rid, 'roomTopic', params); + await sdk.rest.post('/v1/rooms.saveRoomSettings', { rid: message.rid, roomTopic: params }); await clientCallbacks.run('roomTopicChanged', Rooms.state.get(message.rid)); } catch (error: unknown) { dispatchToastMessage({ type: 'error', message: error }); diff --git a/apps/meteor/client/apps/gameCenter/GameCenterInvitePlayersModal.tsx b/apps/meteor/client/apps/gameCenter/GameCenterInvitePlayersModal.tsx index e3a6f13271d01..e447038f70ae7 100644 --- a/apps/meteor/client/apps/gameCenter/GameCenterInvitePlayersModal.tsx +++ b/apps/meteor/client/apps/gameCenter/GameCenterInvitePlayersModal.tsx @@ -7,6 +7,7 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { IGame } from './GameCenter'; +import { sdk } from '../../../app/utils/client/lib/SDKClient'; import UserAutoCompleteMultiple from '../../components/UserAutoCompleteMultiple'; import { useOpenedRoom } from '../../lib/RoomManager'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; @@ -30,14 +31,14 @@ const GameCenterInvitePlayersModal = ({ game, onClose }: IGameCenterInvitePlayer const privateGroupName = `${name.replace(/\s/g, '-')}-${Random.id(10)}`; try { - const result = await callWithErrorHandling('createPrivateGroup' as any, privateGroupName, users); + const { group } = await sdk.rest.post('/v1/groups.create', { name: privateGroupName, members: users }); - roomCoordinator.openRouteLink(result.t, result); + roomCoordinator.openRouteLink(group.t, { rid: group._id, name: group.name }); - if (openedRoom === result.rid) { - callWithErrorHandling('sendMessage', { + if (openedRoom === group._id) { + await callWithErrorHandling('sendMessage', { _id: Random.id(), - rid: result.rid, + rid: group._id, msg: t('Apps_Game_Center_Play_Game_Together', { name }), }); } diff --git a/apps/meteor/client/components/message/toolbar/useTranslateAction.ts b/apps/meteor/client/components/message/toolbar/useTranslateAction.ts index 8b377471bf572..30e7c5a929bda 100644 --- a/apps/meteor/client/components/message/toolbar/useTranslateAction.ts +++ b/apps/meteor/client/components/message/toolbar/useTranslateAction.ts @@ -1,5 +1,5 @@ import type { IMessage, ISubscription, IRoom } from '@rocket.chat/core-typings'; -import { useMethod, usePermission, useSetting, useUser } from '@rocket.chat/ui-contexts'; +import { useEndpoint, usePermission, useSetting, useUser } from '@rocket.chat/ui-contexts'; import { useMemo } from 'react'; import { AutoTranslate } from '../../../../app/autotranslate/client'; @@ -15,7 +15,7 @@ export const useTranslateAction = ( const user = useUser(); const autoTranslateEnabled = useSetting('AutoTranslate_Enabled', false); const canAutoTranslate = usePermission('auto-translate'); - const translateMessage = useMethod('autoTranslate.translateMessage'); + const translateMessage = useEndpoint('POST', '/v1/autotranslate.translateMessage'); const language = useMemo( () => subscription?.autoTranslateLanguage || AutoTranslate.getLanguage(message.rid), @@ -54,7 +54,7 @@ export const useTranslateAction = ( (record) => record._id === message._id, (record) => ({ ...record, autoTranslateFetching: true }), ); - void translateMessage(message, language); + void translateMessage({ messageId: message._id, targetLanguage: language }); } updateMessages( diff --git a/apps/meteor/client/components/message/toolbar/useViewOriginalTranslationAction.ts b/apps/meteor/client/components/message/toolbar/useViewOriginalTranslationAction.ts index 1ba6e7c98310a..b8958becaadc2 100644 --- a/apps/meteor/client/components/message/toolbar/useViewOriginalTranslationAction.ts +++ b/apps/meteor/client/components/message/toolbar/useViewOriginalTranslationAction.ts @@ -1,5 +1,5 @@ import type { IMessage, IRoom, ISubscription } from '@rocket.chat/core-typings'; -import { useMethod, usePermission, useSetting, useUser } from '@rocket.chat/ui-contexts'; +import { useEndpoint, usePermission, useSetting, useUser } from '@rocket.chat/ui-contexts'; import { useMemo } from 'react'; import { AutoTranslate } from '../../../../app/autotranslate/client'; @@ -15,7 +15,7 @@ export const useViewOriginalTranslationAction = ( const user = useUser(); const autoTranslateEnabled = useSetting('AutoTranslate_Enabled', false); const canAutoTranslate = usePermission('auto-translate'); - const translateMessage = useMethod('autoTranslate.translateMessage'); + const translateMessage = useEndpoint('POST', '/v1/autotranslate.translateMessage'); const language = useMemo( () => subscription?.autoTranslateLanguage || AutoTranslate.getLanguage(message.rid), @@ -54,7 +54,7 @@ export const useViewOriginalTranslationAction = ( (record) => record._id === message._id, (record) => ({ ...record, autoTranslateFetching: true }), ); - void translateMessage(message, language); + void translateMessage({ messageId: message._id, targetLanguage: language }); } updateMessages( diff --git a/apps/meteor/client/hooks/notification/useNotification.ts b/apps/meteor/client/hooks/notification/useNotification.ts index cb6fe93077d34..5f2f9ea6891ab 100644 --- a/apps/meteor/client/hooks/notification/useNotification.ts +++ b/apps/meteor/client/hooks/notification/useNotification.ts @@ -33,7 +33,7 @@ export const useNotification = () => { } as any); const n = new Notification(notification.title, { - icon: notification.icon || getUserAvatarURL(notification.payload.sender?.username as string), + icon: notification.icon || getUserAvatarURL(notification.payload.sender?.username), body: stripTags(message?.msg), tag: msgId, canReply: true, diff --git a/apps/meteor/client/lib/chats/data.ts b/apps/meteor/client/lib/chats/data.ts index 96d576e64f3ad..fe22df8d10646 100644 --- a/apps/meteor/client/lib/chats/data.ts +++ b/apps/meteor/client/lib/chats/data.ts @@ -9,6 +9,7 @@ import { sdk } from '../../../app/utils/client/lib/SDKClient'; import { Messages, Rooms, Subscriptions } from '../../stores'; import { settings } from '../settings'; import { getUserId } from '../user'; +import { mapMessageFromApi } from '../utils/mapMessageFromApi'; import { prependReplies } from '../utils/prependReplies'; import { upsertThreadMessageInCache } from '../utils/threadMessageUtils'; @@ -34,7 +35,8 @@ export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage }; const findMessageByID = async (mid: IMessage['_id']): Promise => - Messages.state.find((record) => record._id === mid && record._hidden !== true) ?? sdk.call('getSingleMessage', mid); + Messages.state.find((record) => record._id === mid && record._hidden !== true) ?? + sdk.rest.get('/v1/chat.getMessage', { msgId: mid }).then((response) => mapMessageFromApi(response.message)); const getMessageByID = async (mid: IMessage['_id']): Promise => { const message = await findMessageByID(mid); @@ -68,14 +70,14 @@ export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage } const canEditMessage = hasAtLeastOnePermission('edit-message', message.rid); - const editAllowed = (settings.peek('Message_AllowEditing') as boolean | undefined) ?? false; + const editAllowed = settings.peek('Message_AllowEditing') ?? false; const editOwn = message?.u && message.u._id === getUserId(); if (!canEditMessage && (!editAllowed || !editOwn)) { return false; } - const blockEditInMinutes = settings.peek('Message_AllowEditing_BlockEditInMinutes') as number | undefined; + const blockEditInMinutes = settings.peek('Message_AllowEditing_BlockEditInMinutes'); const bypassBlockTimeLimit = hasPermission('bypass-time-limit-edit-and-delete', message.rid); const elapsedMinutes = differenceInMinutes(new Date(), message.ts); @@ -206,7 +208,7 @@ export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage return true; } - const deletionEnabled = settings.peek('Message_AllowDeleting') as boolean | undefined; + const deletionEnabled = settings.peek('Message_AllowDeleting'); if (!deletionEnabled) { return false; } @@ -219,7 +221,7 @@ export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage return false; } - const blockDeleteInMinutes = settings.peek('Message_AllowDeleting_BlockDeleteInMinutes') as number | undefined; + const blockDeleteInMinutes = settings.peek('Message_AllowDeleting_BlockDeleteInMinutes'); const bypassBlockTimeLimit = hasPermission('bypass-time-limit-edit-and-delete', message.rid); const elapsedMinutes = differenceInMinutes(new Date(), message.ts); const onTimeForDelete = bypassBlockTimeLimit || !blockDeleteInMinutes || !elapsedMinutes || elapsedMinutes <= blockDeleteInMinutes; diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts index e349e404fa08a..afbbdcc13f162 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.room.ts @@ -401,7 +401,7 @@ export class E2ERoom extends Emitter { // Import session key for use. try { - const key = await Aes.importKey(JSON.parse(this.sessionKeyExportedString!)); + const key = await Aes.importKey(JSON.parse(this.sessionKeyExportedString)); // Key has been obtained. E2E is now in session. this.groupSessionKey = key; span.info('Group key imported'); @@ -423,7 +423,7 @@ export class E2ERoom extends Emitter { async createGroupKey() { await this.createNewGroupKey(); - await sdk.call('e2e.setRoomKeyID', this.roomId, this.keyID); + await sdk.rest.post('/v1/e2e.setRoomKeyID', { rid: this.roomId, keyID: this.keyID }); const myKey = await this.encryptGroupKeyForParticipant(e2e.publicKey!); if (myKey) { await sdk.rest.post('/v1/e2e.updateGroupKey', { @@ -482,7 +482,9 @@ export class E2ERoom extends Emitter { try { const mySub = Subscriptions.state.find((record) => record.rid === this.roomId); const decryptedOldGroupKeys = await this.exportOldRoomKeys(mySub?.oldRoomKeys); - const users = (await sdk.call('e2e.getUsersOfRoomWithoutKey', this.roomId)).users.filter((user) => user?.e2e?.public_key); + const users = (await sdk.rest.get('/v1/e2e.getUsersOfRoomWithoutKey', { rid: this.roomId })).users.filter( + (user) => user?.e2e?.public_key, + ); if (!users.length) { span.info('No users to encrypt the key for'); @@ -498,13 +500,13 @@ export class E2ERoom extends Emitter { }[] > = { [this.roomId]: [] }; for await (const user of users) { - const encryptedGroupKey = await this.encryptGroupKeyForParticipant(user.e2e!.public_key!); + const encryptedGroupKey = await this.encryptGroupKeyForParticipant(user.e2e!.public_key); if (!encryptedGroupKey) { span.warn(`Could not encrypt group key for user ${user._id}`); return; } if (decryptedOldGroupKeys) { - const oldKeys = await this.encryptOldKeysForParticipant(user.e2e!.public_key!, decryptedOldGroupKeys); + const oldKeys = await this.encryptOldKeysForParticipant(user.e2e!.public_key, decryptedOldGroupKeys); if (oldKeys) { usersSuggestedGroupKeys[this.roomId].push({ _id: user._id, key: encryptedGroupKey, oldKeys }); continue; diff --git a/apps/meteor/client/lib/utils/mapCustomUserStatusFromApi.ts b/apps/meteor/client/lib/utils/mapCustomUserStatusFromApi.ts new file mode 100644 index 0000000000000..0510f871b6daa --- /dev/null +++ b/apps/meteor/client/lib/utils/mapCustomUserStatusFromApi.ts @@ -0,0 +1,6 @@ +import type { ICustomUserStatus, Serialized } from '@rocket.chat/core-typings'; + +export const mapCustomUserStatusFromApi = ({ _updatedAt, ...status }: Serialized): ICustomUserStatus => ({ + ...status, + _updatedAt: new Date(_updatedAt), +}); diff --git a/apps/meteor/client/lib/utils/mapReadReceiptFromApi.ts b/apps/meteor/client/lib/utils/mapReadReceiptFromApi.ts new file mode 100644 index 0000000000000..f0c0cb28463fa --- /dev/null +++ b/apps/meteor/client/lib/utils/mapReadReceiptFromApi.ts @@ -0,0 +1,7 @@ +import type { IReadReceiptWithUser, Serialized } from '@rocket.chat/core-typings'; + +export const mapReadReceiptFromApi = ({ ts, _updatedAt, ...receipt }: Serialized): IReadReceiptWithUser => ({ + ...receipt, + ts: new Date(ts), + _updatedAt: new Date(_updatedAt), +}); diff --git a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx index 192e93aab46b6..a5d7f3663bc87 100644 --- a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx +++ b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx @@ -1,9 +1,10 @@ +import type { ICustomUserStatus } from '@rocket.chat/core-typings'; import { Box } from '@rocket.chat/fuselage'; import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; import { clientCallbacks } from '@rocket.chat/ui-client'; -import { useEndpoint, useMethod, useSetting, useStream } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useSetting, useStream } from '@rocket.chat/ui-contexts'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useCustomStatusModalHandler } from './useCustomStatusModalHandler'; @@ -12,6 +13,7 @@ import { UserStatus } from '../../../../components/UserStatus'; import { useFireGlobalEvent } from '../../../../hooks/useFireGlobalEvent'; import { userStatuses } from '../../../../lib/userStatuses'; import type { UserStatusDescriptor } from '../../../../lib/userStatuses'; +import { mapCustomUserStatusFromApi } from '../../../../lib/utils/mapCustomUserStatusFromApi'; import { useStatusDisabledModal } from '../../../../views/admin/customUserStatus/hooks/useStatusDisabledModal'; export const useStatusItems = (): GenericMenuItemProps[] => { @@ -21,7 +23,20 @@ export const useStatusItems = (): GenericMenuItemProps[] => { const queryClient = useQueryClient(); const stream = useStream('notify-logged'); - const listCustomUserStatus = useMethod('listCustomUserStatus'); + const listCustomUserStatusEndpoint = useEndpoint('GET', '/v1/custom-user-status.list'); + const listCustomUserStatus = useCallback(async (): Promise => { + const all: ICustomUserStatus[] = []; + const count = 100; + let offset = 0; + // REST endpoint is paginated; loop until total reached. + while (true) { + const { statuses, total } = await listCustomUserStatusEndpoint({ count, offset }); + all.push(...statuses.map(mapCustomUserStatusFromApi)); + if (all.length >= total || statuses.length === 0) break; + offset += statuses.length; + } + return all; + }, [listCustomUserStatusEndpoint]); useEffect( () => diff --git a/apps/meteor/client/views/account/tokens/AccountTokensTable/AccountTokensTable.tsx b/apps/meteor/client/views/account/tokens/AccountTokensTable/AccountTokensTable.tsx index 5da4ff48563e6..517e737faa865 100644 --- a/apps/meteor/client/views/account/tokens/AccountTokensTable/AccountTokensTable.tsx +++ b/apps/meteor/client/views/account/tokens/AccountTokensTable/AccountTokensTable.tsx @@ -8,7 +8,7 @@ import { GenericTableHeaderCell, usePagination, } from '@rocket.chat/ui-client'; -import { useSetModal, useToastMessageDispatch, useUserId, useMethod, useEndpoint } from '@rocket.chat/ui-contexts'; +import { useSetModal, useToastMessageDispatch, useUserId, useEndpoint } from '@rocket.chat/ui-contexts'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { ReactElement, RefObject } from 'react'; import { useMemo, useCallback } from 'react'; @@ -26,8 +26,8 @@ const AccountTokensTable = (): ReactElement => { const setModal = useSetModal(); const userId = useUserId(); - const regenerateToken = useMethod('personalAccessTokens:regenerateToken'); - const removeToken = useMethod('personalAccessTokens:removeToken'); + const regenerateToken = useEndpoint('POST', '/v1/users.regeneratePersonalAccessToken'); + const removeToken = useEndpoint('POST', '/v1/users.removePersonalAccessToken'); const getPersonalAccessTokens = useEndpoint('GET', '/v1/users.getPersonalAccessTokens'); const { isPending, isSuccess, data, isError, error } = useQuery({ @@ -69,7 +69,7 @@ const AccountTokensTable = (): ReactElement => { const onConfirm: () => Promise = async () => { try { setModal(null); - const token = await regenerateToken({ tokenName: name }); + const { token } = await regenerateToken({ tokenName: name }); setModal( diff --git a/apps/meteor/client/views/account/tokens/AccountTokensTable/AddToken.tsx b/apps/meteor/client/views/account/tokens/AccountTokensTable/AddToken.tsx index d790a66089ac1..43b99294f81ab 100644 --- a/apps/meteor/client/views/account/tokens/AccountTokensTable/AddToken.tsx +++ b/apps/meteor/client/views/account/tokens/AccountTokensTable/AddToken.tsx @@ -1,7 +1,7 @@ import type { SelectOption } from '@rocket.chat/fuselage'; import { Box, TextInput, Button, Margins, Select, FieldError, FieldGroup, Field, FieldRow } from '@rocket.chat/fuselage'; import { GenericModal } from '@rocket.chat/ui-client'; -import { useSetModal, useToastMessageDispatch, useUserId, useMethod } from '@rocket.chat/ui-contexts'; +import { useSetModal, useToastMessageDispatch, useUserId, useEndpoint } from '@rocket.chat/ui-contexts'; import { useCallback, useId, useMemo } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { Trans, useTranslation } from 'react-i18next'; @@ -19,7 +19,7 @@ const AddToken = ({ reload }: AddTokenProps) => { const { t } = useTranslation(); const userId = useUserId(); const setModal = useSetModal(); - const createTokenFn = useMethod('personalAccessTokens:generateToken'); + const createTokenFn = useEndpoint('POST', '/v1/users.generatePersonalAccessToken'); const dispatchToastMessage = useToastMessageDispatch(); const initialValues = useMemo(() => ({ name: '', bypassTwoFactor: 'require' }), []); @@ -42,7 +42,7 @@ const AddToken = ({ reload }: AddTokenProps) => { const handleAddToken = useCallback( async ({ name: tokenName, bypassTwoFactor }: AddTokenFormData) => { try { - const token = await createTokenFn({ tokenName, bypassTwoFactor: bypassTwoFactor === 'bypass' }); + const { token } = await createTokenFn({ tokenName, bypassTwoFactor: bypassTwoFactor === 'bypass' }); const handleDismissModal = () => { setModal(null); diff --git a/apps/meteor/client/views/admin/workspace/VersionCard/modals/RegisteredWorkspaceModal.tsx b/apps/meteor/client/views/admin/workspace/VersionCard/modals/RegisteredWorkspaceModal.tsx index 999353071bf06..5992160c58b99 100644 --- a/apps/meteor/client/views/admin/workspace/VersionCard/modals/RegisteredWorkspaceModal.tsx +++ b/apps/meteor/client/views/admin/workspace/VersionCard/modals/RegisteredWorkspaceModal.tsx @@ -11,7 +11,7 @@ import { ModalTitle, } from '@rocket.chat/fuselage'; import { useSafely } from '@rocket.chat/fuselage-hooks'; -import { useMethod, useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -29,22 +29,23 @@ const RegisteredWorkspaceModal = ({ onClose, onStatusChange, ...props }: Registe const bulletFeatures = useFeatureBullets(); const [isSyncing, setSyncing] = useSafely(useState(false)); - const syncWorkspace = useMethod('cloud:syncWorkspace'); + const syncWorkspace = useEndpoint('POST', '/v1/cloud.syncWorkspace'); const handleSyncAction = async () => { setSyncing(true); try { - const isSynced = await syncWorkspace(); + const { success } = await syncWorkspace(); - if (!isSynced) { + if (!success) { throw Error(t('RegisterWorkspace_Syncing_Error')); } dispatchToastMessage({ type: 'success', message: t('RegisterWorkspace_Syncing_Complete') }); setModal(null); } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); + const message = error instanceof Error ? error.message : t('RegisterWorkspace_Syncing_Error'); + dispatchToastMessage({ type: 'error', message }); } finally { onStatusChange?.(); setSyncing(false); diff --git a/apps/meteor/client/views/room/MessageList/hooks/useLoadSurroundingMessages.spec.ts b/apps/meteor/client/views/room/MessageList/hooks/useLoadSurroundingMessages.spec.ts index 3bc48f9e0ff8d..c699059d9f6d9 100644 --- a/apps/meteor/client/views/room/MessageList/hooks/useLoadSurroundingMessages.spec.ts +++ b/apps/meteor/client/views/room/MessageList/hooks/useLoadSurroundingMessages.spec.ts @@ -130,7 +130,7 @@ describe('useLoadSurroundingMessages', () => { message, }) as any, ) - .withMethod('getRoomById', () => ({ _id: 'room-2', t: 'c', name: 'general' }) as any) + .withEndpoint('GET', '/v1/rooms.info', () => ({ room: { _id: 'room-2', t: 'c', name: 'general' } }) as any) .build(), }); @@ -241,7 +241,7 @@ describe('useLoadSurroundingMessages', () => { message, }) as any, ) - .withMethod('getRoomById', () => ({ _id: 'room-5', t: 'c', name: 'general' }) as any) + .withEndpoint('GET', '/v1/rooms.info', () => ({ room: { _id: 'room-5', t: 'c', name: 'general' } }) as any) .build(), }); diff --git a/apps/meteor/client/views/room/composer/ComposerBoxPopupPreview.tsx b/apps/meteor/client/views/room/composer/ComposerBoxPopupPreview.tsx index df17ff5d5d8de..dfa02a7829945 100644 --- a/apps/meteor/client/views/room/composer/ComposerBoxPopupPreview.tsx +++ b/apps/meteor/client/views/room/composer/ComposerBoxPopupPreview.tsx @@ -1,5 +1,6 @@ import { Box, Skeleton, Tile, Option } from '@rocket.chat/fuselage'; -import { useMethod } from '@rocket.chat/ui-contexts'; +import { Random } from '@rocket.chat/random'; +import { useEndpoint } from '@rocket.chat/ui-contexts'; import type { ForwardedRef, ReactNode } from 'react'; import { forwardRef, useEffect, useId, useImperativeHandle } from 'react'; @@ -27,7 +28,7 @@ const ComposerBoxPopupPreview = forwardRef(function ComposerBoxPopupPreview( ) { const id = useId(); const chat = useChat(); - const executeSlashCommandPreviewMethod = useMethod('executeSlashCommandPreview'); + const executeSlashCommandPreviewEndpoint = useEndpoint('POST', '/v1/commands.preview'); useImperativeHandle( ref, @@ -63,13 +64,19 @@ const ComposerBoxPopupPreview = forwardRef(function ComposerBoxPopupPreview( const cmd = matches[1].replace('/', '').trim().toLowerCase(); const params = matches[2]; - // TODO: Fix this solve the typing issue - void executeSlashCommandPreviewMethod({ cmd, params, msg: { rid, tmid } }, { id: item._id, type: item.type, value: item.value }); + void executeSlashCommandPreviewEndpoint({ + command: cmd, + params, + roomId: rid, + ...(tmid && { tmid }), + triggerId: Random.id(), + previewItem: { id: item._id, type: item.type, value: item.value }, + }); chat?.composer?.setText(''); }, }), }), - [chat?.composer, executeSlashCommandPreviewMethod, rid, tmid, suspended], + [chat?.composer, executeSlashCommandPreviewEndpoint, rid, tmid, suspended], ); const itemsFlat = items diff --git a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.ts b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.ts index 5966aa17cad4f..4a6e1e7a2202b 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.ts +++ b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.ts @@ -1,10 +1,11 @@ import { isThreadMessage, type IMessage, type IRoom, type IThreadMainMessage, type IThreadMessage } from '@rocket.chat/core-typings'; -import { useMethod, useStream } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useMethod, useStream } from '@rocket.chat/ui-contexts'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useRef } from 'react'; import { onClientMessageReceived } from '../../../../../lib/onClientMessageReceived'; import { roomsQueryKeys } from '../../../../../lib/queryKeys'; +import { mapMessageFromApi } from '../../../../../lib/utils/mapMessageFromApi'; import { modifyMessageOnFilesDelete } from '../../../../../lib/utils/modifyMessageOnFilesDelete'; import { createDeleteCriteria, @@ -24,7 +25,11 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR const queryClient = useQueryClient(); const queryKey = roomsQueryKeys.threadMessages(roomId, tmid); - const getThreadMessages = useMethod('getThreadMessages'); + const getThreadMessages = useEndpoint('GET', '/v1/chat.getThreadMessages'); + // REST has no per-thread read-marker endpoint yet; fall back to the + // `readThreads` DDP method so the side effect that DDP getThreadMessages + // used to do server-side keeps happening for callers. + const readThreads = useMethod('readThreads'); const subscribeToRoomMessages = useStream('room-messages'); const subscribeToNotifyRoom = useStream('notify-room'); @@ -105,10 +110,11 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR queryFn: async () => { const cachedMessages = queryClient.getQueryData(queryKey) || []; - const messages = await getThreadMessages({ tmid }); - const filtered = messages.filter( - (msg): msg is IThreadMessage => isThreadMessage(msg) && msg.tmid === tmid && msg._id !== tmid && msg._hidden !== true, - ); + const { messages } = await getThreadMessages({ tmid }); + void Promise.resolve(readThreads(tmid)).catch(() => undefined); + const filtered = messages + .map((m) => mapMessageFromApi(m)) + .filter((msg): msg is IThreadMessage => isThreadMessage(msg) && msg.tmid === tmid && msg._id !== tmid && msg._hidden !== true); const sorted = mergeThreadMessages(cachedMessages, filtered); if (unprocessedReadMessagesEvent.current) { diff --git a/apps/meteor/client/views/room/hooks/useGoToRoom.ts b/apps/meteor/client/views/room/hooks/useGoToRoom.ts index 2c2569ab0bc01..373f1530b5877 100644 --- a/apps/meteor/client/views/room/hooks/useGoToRoom.ts +++ b/apps/meteor/client/views/room/hooks/useGoToRoom.ts @@ -1,6 +1,6 @@ import type { IRoom, ISubscription } from '@rocket.chat/core-typings'; import { useStableCallback } from '@rocket.chat/fuselage-hooks'; -import { useMethod, useRouter, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useRouter, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import { roomCoordinator } from '../../../lib/rooms/roomCoordinator'; import { Subscriptions } from '../../../stores'; @@ -12,7 +12,7 @@ type GoToRoomByIdOptions = { export const useGoToRoom = (): ((roomId: IRoom['_id'], options?: GoToRoomByIdOptions) => Promise) => { const router = useRouter(); - const getRoomById = useMethod('getRoomById'); + const getRoomInfo = useEndpoint('GET', '/v1/rooms.info'); const dispatchToastMessage = useToastMessageDispatch(); // TODO: remove params recycling @@ -27,7 +27,10 @@ export const useGoToRoom = (): ((roomId: IRoom['_id'], options?: GoToRoomByIdOpt } try { - const room = await getRoomById(roomId); + const { room } = await getRoomInfo({ roomId }); + if (!room) { + throw new Error('Room not found'); + } roomCoordinator.openRouteLink(room.t, { rid: room._id, ...room }, router.getSearchParameters(), options); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); diff --git a/apps/meteor/client/views/room/hooks/useOpenRoom.ts b/apps/meteor/client/views/room/hooks/useOpenRoom.ts index ee4b28fc19c47..284c632921e1e 100644 --- a/apps/meteor/client/views/room/hooks/useOpenRoom.ts +++ b/apps/meteor/client/views/room/hooks/useOpenRoom.ts @@ -1,6 +1,6 @@ import { isPublicRoom, type IRoom, type RoomType } from '@rocket.chat/core-typings'; import { getObjectKeys } from '@rocket.chat/tools'; -import { useMethod, usePermission, useRoute, useSetting, useUser } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useMethod, usePermission, useRoute, useSetting, useUser } from '@rocket.chat/ui-contexts'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect } from 'react'; @@ -18,7 +18,7 @@ export function useOpenRoom({ type, reference }: { type: RoomType; reference: st const hasPreviewPermission = usePermission('preview-c-room'); const allowAnonymousRead = useSetting('Accounts_AllowAnonymousRead', true); const getRoomByTypeAndName = useMethod('getRoomByTypeAndName'); - const createDirectMessage = useMethod('createDirectMessage'); + const createDirectMessage = useEndpoint('POST', '/v1/im.create'); const directRoute = useRoute('direct'); const openRoom = useOpenRoomMutation(); @@ -44,9 +44,9 @@ export function useOpenRoom({ type, reference }: { type: RoomType; reference: st } try { - const { rid } = await createDirectMessage(...reference.split(', ')); + const { room } = await createDirectMessage({ usernames: reference }); - directRoute.push({ rid }, (prev) => prev); + directRoute.push({ rid: room._id }, (prev) => prev); } catch (error) { throw new RoomNotFoundError(undefined, { type, reference }); } diff --git a/apps/meteor/client/views/room/providers/ComposerPopupProvider.tsx b/apps/meteor/client/views/room/providers/ComposerPopupProvider.tsx index afce0c7cd751a..6cf9eb4ee411f 100644 --- a/apps/meteor/client/views/room/providers/ComposerPopupProvider.tsx +++ b/apps/meteor/client/views/room/providers/ComposerPopupProvider.tsx @@ -3,7 +3,7 @@ import { isOmnichannelRoom } from '@rocket.chat/core-typings'; import { useLocalStorage } from '@rocket.chat/fuselage-hooks'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; -import { useMethod, useSetting, useUserId, useUserPreference } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useMethod, useSetting, useUserId, useUserPreference } from '@rocket.chat/ui-contexts'; import { useQueryClient } from '@tanstack/react-query'; import { useMemo, useState } from 'react'; import type { ReactNode } from 'react'; @@ -83,7 +83,7 @@ const ComposerPopupProvider = ({ children, room }: ComposerPopupProviderProps) = const encrypted = isRoomEncrypted && e2eEnabled && !unencryptedMessagesAllowed; const queryClient = useQueryClient(); const uid = useUserId(); - const call = useMethod('getSlashCommandPreviews'); + const call = useEndpoint('GET', '/v1/commands.preview'); const value: ComposerPopupContextValue = useMemo(() => { return [ @@ -371,13 +371,13 @@ const ComposerPopupProvider = ({ children, room }: ComposerPopupProviderProps) = title: previewTitle, matchSelectorRegex: /(?:^)(\/[\w\d\S]+ )[^]*$/, preview: true, - getItemsFromLocal: async ({ cmd, params, tmid }: { cmd: string; params: string; tmid: string }) => { - const result = await call({ cmd, params, msg: { rid, tmid } }); + getItemsFromLocal: async ({ cmd, params }: { cmd: string; params: string; tmid: string }) => { + const { preview } = await call({ command: cmd, params, roomId: rid }); - setPreviewTitle(t(result?.i18nTitle ?? '')); + setPreviewTitle(t(preview?.i18nTitle ?? '')); return ( - result?.items.map((item) => ({ + preview?.items.map((item) => ({ _id: item.id, value: item.value, type: item.type, From 7af134cbe329f8805486fb0ddfbe2533533a0ee1 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 26 May 2026 09:55:48 -0600 Subject: [PATCH 014/161] fix: UI doesnt send UTC timestamps as filters for `abac/audit` page (#40684) --- .changeset/pretty-rocks-retire.md | 5 +++++ apps/meteor/client/views/admin/ABAC/ABACLogsTab/LogsPage.tsx | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .changeset/pretty-rocks-retire.md diff --git a/.changeset/pretty-rocks-retire.md b/.changeset/pretty-rocks-retire.md new file mode 100644 index 0000000000000..b290e9c4cf10f --- /dev/null +++ b/.changeset/pretty-rocks-retire.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes an issue on ABAC audit page that sent local timestamps instead of UTC ones as filters to fetch audit logs diff --git a/apps/meteor/client/views/admin/ABAC/ABACLogsTab/LogsPage.tsx b/apps/meteor/client/views/admin/ABAC/ABACLogsTab/LogsPage.tsx index 50b5d49595f9e..62ee57e3680d4 100644 --- a/apps/meteor/client/views/admin/ABAC/ABACLogsTab/LogsPage.tsx +++ b/apps/meteor/client/views/admin/ABAC/ABACLogsTab/LogsPage.tsx @@ -32,8 +32,8 @@ const LogsPage = () => { const getLogs = useEndpoint('GET', '/v1/abac/audit'); const query = useMemo( () => ({ - ...(startDate && { start: new Date(`${startDate}T00:00:00.000Z`).toISOString() }), - ...(endDate && { end: new Date(`${endDate}T23:59:59.999Z`).toISOString() }), + ...(startDate && { start: new Date(`${startDate}T00:00:00.000`).toISOString() }), + ...(endDate && { end: new Date(`${endDate}T23:59:59.999`).toISOString() }), offset: current, count: itemsPerPage, }), From f9f9b155641b1f57de8433d6891f611dd70d10f7 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Tue, 26 May 2026 17:03:22 -0300 Subject: [PATCH 015/161] test: scope report-message locator to moderation table (#40700) --- apps/meteor/tests/e2e/report-message.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/meteor/tests/e2e/report-message.spec.ts b/apps/meteor/tests/e2e/report-message.spec.ts index f12b4eff6ec67..8a4274f84ebb3 100644 --- a/apps/meteor/tests/e2e/report-message.spec.ts +++ b/apps/meteor/tests/e2e/report-message.spec.ts @@ -111,8 +111,9 @@ test.describe.serial('report message', () => { await adminPage.goto('/admin/moderation/messages'); await expect(adminPage.getByRole('tab', { name: 'Reported messages' })).toBeVisible(); - await expect(adminPage.getByRole('link', { name: 'user1' })).toBeVisible(); - await adminPage.getByRole('link', { name: 'user1' }).click(); + const reportedUserLink = adminPage.getByRole('table').getByRole('link', { name: 'user1' }); + await expect(reportedUserLink).toBeVisible(); + await reportedUserLink.click(); await expect(adminPage.getByText(testMessage)).toBeVisible(); From 8e5f1855e16bdd8fc592681616911d14a6386df7 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Tue, 26 May 2026 17:05:42 -0300 Subject: [PATCH 016/161] chore: log deprecation for orphan DDP methods that have a REST replacement (#40654) --- .../meteor/app/autotranslate/server/methods/saveSettings.ts | 2 ++ apps/meteor/app/cloud/server/methods.ts | 2 ++ .../app/discussion/server/methods/createDiscussion.ts | 4 +++- apps/meteor/app/e2e/server/methods/fetchMyKeys.ts | 3 +++ .../app/e2e/server/methods/setUserPublicAndPrivateKeys.ts | 2 ++ .../app/emoji-custom/server/methods/deleteEmojiCustom.ts | 2 ++ .../app/emoji-custom/server/methods/insertOrUpdateEmoji.ts | 2 ++ .../app/emoji-custom/server/methods/uploadEmojiCustom.ts | 2 ++ .../app/importer/server/methods/downloadPublicImportFile.ts | 2 ++ .../meteor/app/importer/server/methods/getImportFileData.ts | 2 ++ .../meteor/app/importer/server/methods/getImportProgress.ts | 2 ++ .../importer/server/methods/getLatestImportOperations.ts | 2 ++ apps/meteor/app/importer/server/methods/startImport.ts | 2 ++ apps/meteor/app/importer/server/methods/uploadImportFile.ts | 2 ++ .../server/methods/incoming/addIncomingIntegration.ts | 2 ++ .../server/methods/incoming/deleteIncomingIntegration.ts | 2 ++ .../server/methods/incoming/updateIncomingIntegration.ts | 2 ++ .../server/methods/outgoing/addOutgoingIntegration.ts | 2 ++ .../server/methods/outgoing/deleteOutgoingIntegration.ts | 2 ++ .../server/methods/outgoing/updateOutgoingIntegration.ts | 2 ++ apps/meteor/app/lib/server/methods/addUserToRoom.ts | 2 ++ apps/meteor/app/lib/server/methods/archiveRoom.ts | 2 ++ apps/meteor/app/lib/server/methods/createChannel.ts | 2 ++ apps/meteor/app/lib/server/methods/deleteUserOwnAccount.ts | 2 ++ apps/meteor/app/lib/server/methods/getChannelHistory.ts | 2 ++ apps/meteor/app/lib/server/methods/getUsernameSuggestion.ts | 2 ++ apps/meteor/app/lib/server/methods/saveSetting.ts | 6 ++++-- apps/meteor/app/lib/server/methods/saveSettings.ts | 4 ++-- apps/meteor/app/lib/server/methods/sendMessage.ts | 1 - apps/meteor/app/lib/server/methods/setEmail.ts | 4 +++- apps/meteor/app/lib/server/methods/setRealName.ts | 2 ++ apps/meteor/app/lib/server/methods/unarchiveRoom.ts | 2 ++ .../app/livechat/server/methods/sendMessageLivechat.ts | 2 ++ .../app/mentions/server/methods/getUserMentionsByChannel.ts | 2 ++ .../app/message-mark-as-unread/server/unreadMessages.ts | 2 ++ apps/meteor/app/message-pin/server/pinMessage.ts | 3 +++ .../server/admin/methods/deleteOAuthApp.ts | 2 ++ .../server/admin/methods/updateOAuthApp.ts | 2 ++ .../server/methods/saveNotificationSettings.ts | 2 ++ apps/meteor/app/push/server/methods.ts | 2 ++ apps/meteor/app/statistics/server/methods/getStatistics.ts | 2 ++ apps/meteor/app/threads/server/methods/followMessage.ts | 2 ++ apps/meteor/app/threads/server/methods/getThreadsList.ts | 2 ++ apps/meteor/app/threads/server/methods/unfollowMessage.ts | 2 ++ .../user-status/server/methods/deleteCustomUserStatus.ts | 2 ++ .../app/user-status/server/methods/getUserStatusText.ts | 2 ++ .../user-status/server/methods/insertOrUpdateUserStatus.ts | 2 ++ apps/meteor/ee/app/license/server/methods.ts | 4 ++++ .../server/api/methods/generateToken.ts | 1 - .../server/api/methods/regenerateToken.ts | 1 - .../server/api/methods/removeToken.ts | 1 - apps/meteor/server/methods/addAllUserToRoom.ts | 2 ++ apps/meteor/server/methods/addRoomLeader.ts | 2 ++ apps/meteor/server/methods/addRoomModerator.ts | 2 ++ apps/meteor/server/methods/addRoomOwner.ts | 2 ++ apps/meteor/server/methods/browseChannels.ts | 2 ++ apps/meteor/server/methods/channelsList.ts | 2 ++ apps/meteor/server/methods/deleteUser.ts | 2 ++ apps/meteor/server/methods/getRoomIdByNameOrId.ts | 2 ++ apps/meteor/server/methods/getRoomNameById.ts | 2 ++ apps/meteor/server/methods/getTotalChannels.ts | 3 +++ apps/meteor/server/methods/hideRoom.ts | 2 ++ apps/meteor/server/methods/ignoreUser.ts | 2 ++ apps/meteor/server/methods/messageSearch.ts | 2 ++ apps/meteor/server/methods/removeRoomLeader.ts | 2 ++ apps/meteor/server/methods/removeRoomModerator.ts | 2 ++ apps/meteor/server/methods/removeRoomOwner.ts | 2 ++ apps/meteor/server/methods/removeUserFromRoom.ts | 2 ++ apps/meteor/server/methods/resetAvatar.ts | 2 ++ apps/meteor/server/methods/saveUserPreferences.ts | 2 ++ apps/meteor/server/methods/toggleFavorite.ts | 2 ++ apps/meteor/server/methods/userPresence.ts | 3 +++ 72 files changed, 146 insertions(+), 10 deletions(-) diff --git a/apps/meteor/app/autotranslate/server/methods/saveSettings.ts b/apps/meteor/app/autotranslate/server/methods/saveSettings.ts index 8472781be6830..fa5badb0e1af5 100644 --- a/apps/meteor/app/autotranslate/server/methods/saveSettings.ts +++ b/apps/meteor/app/autotranslate/server/methods/saveSettings.ts @@ -1,6 +1,7 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { saveAutoTranslateSettings } from '../functions/saveSettings'; declare module '@rocket.chat/ddp-client' { @@ -12,6 +13,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async 'autoTranslate.saveSettings'(rid, field, value, options) { + methodDeprecationLogger.method('autoTranslate.saveSettings', '9.0.0', '/v1/autotranslate.saveSettings'); const userId = Meteor.userId(); if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { diff --git a/apps/meteor/app/cloud/server/methods.ts b/apps/meteor/app/cloud/server/methods.ts index c132dbb1a48c2..ef6e6b34927ea 100644 --- a/apps/meteor/app/cloud/server/methods.ts +++ b/apps/meteor/app/cloud/server/methods.ts @@ -12,6 +12,7 @@ import { startRegisterWorkspace } from './functions/startRegisterWorkspace'; import { syncWorkspace } from './functions/syncWorkspace'; import { userLogout } from './functions/userLogout'; import { hasPermissionAsync } from '../../authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../lib/server/lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -40,6 +41,7 @@ Meteor.methods({ * Prefer using cloud.registrationStatus rest api. */ async 'cloud:checkRegisterStatus'() { + methodDeprecationLogger.method('cloud:checkRegisterStatus', '9.0.0', '/v1/cloud.registrationStatus'); const uid = Meteor.userId(); if (!uid) { diff --git a/apps/meteor/app/discussion/server/methods/createDiscussion.ts b/apps/meteor/app/discussion/server/methods/createDiscussion.ts index 65804410c405a..10e965758c906 100644 --- a/apps/meteor/app/discussion/server/methods/createDiscussion.ts +++ b/apps/meteor/app/discussion/server/methods/createDiscussion.ts @@ -15,6 +15,7 @@ import { attachMessage } from '../../../lib/server/functions/attachMessage'; import { createRoom } from '../../../lib/server/functions/createRoom'; import { sendMessage } from '../../../lib/server/functions/sendMessage'; import { afterSaveMessageAsync } from '../../../lib/server/lib/afterSaveMessage'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { settings } from '../../../settings/server'; const getParentRoom = async (rid: IRoom['_id']) => { @@ -85,7 +86,7 @@ const create = async ({ } if (prid) { const parentRoom = await getParentRoom(message.rid); - if (!parentRoom || prid !== parentRoom._id) { + if (prid !== parentRoom?._id) { throw new Meteor.Error('error-invalid-arguments', 'Root message room ID does not match parent room ID ', { method: 'DiscussionCreation', }); @@ -247,6 +248,7 @@ Meteor.methods({ * @param {boolean} encrypted - if the discussion's e2e encryption should be enabled. */ async createDiscussion({ prid, pmid, t_name: discussionName, reply, users, encrypted }: CreateDiscussionProperties) { + methodDeprecationLogger.method('createDiscussion', '9.0.0', '/v1/rooms.createDiscussion'); check(prid, Match.Maybe(String)); check(pmid, Match.Maybe(String)); check(reply, Match.Maybe(String)); diff --git a/apps/meteor/app/e2e/server/methods/fetchMyKeys.ts b/apps/meteor/app/e2e/server/methods/fetchMyKeys.ts index df1150cee8463..b788542dda765 100644 --- a/apps/meteor/app/e2e/server/methods/fetchMyKeys.ts +++ b/apps/meteor/app/e2e/server/methods/fetchMyKeys.ts @@ -2,6 +2,8 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; + declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { @@ -11,6 +13,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async 'e2e.fetchMyKeys'() { + methodDeprecationLogger.method('e2e.fetchMyKeys', '9.0.0', '/v1/e2e.fetchMyKeys'); const userId = Meteor.userId(); if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'e2e.fetchMyKeys' }); diff --git a/apps/meteor/app/e2e/server/methods/setUserPublicAndPrivateKeys.ts b/apps/meteor/app/e2e/server/methods/setUserPublicAndPrivateKeys.ts index da5f93090e1b8..9d54f9a81b532 100644 --- a/apps/meteor/app/e2e/server/methods/setUserPublicAndPrivateKeys.ts +++ b/apps/meteor/app/e2e/server/methods/setUserPublicAndPrivateKeys.ts @@ -2,6 +2,7 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Rooms, Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { notifyOnRoomChangedById } from '../../../lib/server/lib/notifyListener'; declare module '@rocket.chat/ddp-client' { @@ -42,6 +43,7 @@ export const setUserPublicAndPrivateKeysMethod = async ( Meteor.methods({ async 'e2e.setUserPublicAndPrivateKeys'(keyPair) { + methodDeprecationLogger.method('e2e.setUserPublicAndPrivateKeys', '9.0.0', '/v1/e2e.setUserPublicAndPrivateKeys'); const userId = Meteor.userId(); if (!userId) { diff --git a/apps/meteor/app/emoji-custom/server/methods/deleteEmojiCustom.ts b/apps/meteor/app/emoji-custom/server/methods/deleteEmojiCustom.ts index ce6aaeaf417f6..0ca581f7fe1c1 100644 --- a/apps/meteor/app/emoji-custom/server/methods/deleteEmojiCustom.ts +++ b/apps/meteor/app/emoji-custom/server/methods/deleteEmojiCustom.ts @@ -5,6 +5,7 @@ import { EmojiCustom } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { RocketChatFileEmojiCustomInstance } from '../startup/emoji-custom'; declare module '@rocket.chat/ddp-client' { @@ -35,6 +36,7 @@ export const deleteEmojiCustom = async (userId: string, emojiID: ICustomEmojiDes Meteor.methods({ async deleteEmojiCustom(emojiID) { + methodDeprecationLogger.method('deleteEmojiCustom', '9.0.0', '/v1/emoji-custom.delete'); if (!this.userId) { throw new Meteor.Error('not_authorized'); } diff --git a/apps/meteor/app/emoji-custom/server/methods/insertOrUpdateEmoji.ts b/apps/meteor/app/emoji-custom/server/methods/insertOrUpdateEmoji.ts index 1891d1b3ed95d..ed3bc693bac05 100644 --- a/apps/meteor/app/emoji-custom/server/methods/insertOrUpdateEmoji.ts +++ b/apps/meteor/app/emoji-custom/server/methods/insertOrUpdateEmoji.ts @@ -1,6 +1,7 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { insertOrUpdateEmoji } from '../lib/insertOrUpdateEmoji'; declare module '@rocket.chat/ddp-client' { @@ -20,6 +21,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async insertOrUpdateEmoji(emojiData) { + methodDeprecationLogger.method('insertOrUpdateEmoji', '9.0.0', ['/v1/emoji-custom.create', '/v1/emoji-custom.update']); const emoji = await insertOrUpdateEmoji(this.userId, emojiData); if (!emojiData._id) { diff --git a/apps/meteor/app/emoji-custom/server/methods/uploadEmojiCustom.ts b/apps/meteor/app/emoji-custom/server/methods/uploadEmojiCustom.ts index e387888b1311c..91e6ed8270265 100644 --- a/apps/meteor/app/emoji-custom/server/methods/uploadEmojiCustom.ts +++ b/apps/meteor/app/emoji-custom/server/methods/uploadEmojiCustom.ts @@ -1,6 +1,7 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { uploadEmojiCustom } from '../lib/uploadEmojiCustom'; declare module '@rocket.chat/ddp-client' { @@ -20,6 +21,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async uploadEmojiCustom(binaryContent, contentType, emojiData) { + methodDeprecationLogger.method('uploadEmojiCustom', '9.0.0', '/v1/emoji-custom.create'); await uploadEmojiCustom(this.userId, binaryContent, contentType, emojiData); }, }); diff --git a/apps/meteor/app/importer/server/methods/downloadPublicImportFile.ts b/apps/meteor/app/importer/server/methods/downloadPublicImportFile.ts index a2f60404c4914..2485f5fb487f4 100644 --- a/apps/meteor/app/importer/server/methods/downloadPublicImportFile.ts +++ b/apps/meteor/app/importer/server/methods/downloadPublicImportFile.ts @@ -9,6 +9,7 @@ import { Meteor } from 'meteor/meteor'; import { Importers } from '..'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { ProgressStep } from '../../lib/ImporterProgressStep'; import { RocketChatImportFileInstance } from '../startup/store'; @@ -84,6 +85,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async downloadPublicImportFile(fileUrl: string, importerKey: string) { + methodDeprecationLogger.method('downloadPublicImportFile', '9.0.0', '/v1/downloadPublicImportFile'); const userId = Meteor.userId(); if (!userId) { diff --git a/apps/meteor/app/importer/server/methods/getImportFileData.ts b/apps/meteor/app/importer/server/methods/getImportFileData.ts index e029f4c2bfecf..2688cca265c2d 100644 --- a/apps/meteor/app/importer/server/methods/getImportFileData.ts +++ b/apps/meteor/app/importer/server/methods/getImportFileData.ts @@ -8,6 +8,7 @@ import { Meteor } from 'meteor/meteor'; import { Importers } from '..'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { ProgressStep } from '../../lib/ImporterProgressStep'; import { RocketChatImportFileInstance } from '../startup/store'; @@ -71,6 +72,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async getImportFileData() { + methodDeprecationLogger.method('getImportFileData', '9.0.0', '/v1/getImportFileData'); const userId = Meteor.userId(); if (!userId) { diff --git a/apps/meteor/app/importer/server/methods/getImportProgress.ts b/apps/meteor/app/importer/server/methods/getImportProgress.ts index 7b73030df22e6..2dbad5af56493 100644 --- a/apps/meteor/app/importer/server/methods/getImportProgress.ts +++ b/apps/meteor/app/importer/server/methods/getImportProgress.ts @@ -5,6 +5,7 @@ import { Meteor } from 'meteor/meteor'; import { Importers } from '..'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; export const executeGetImportProgress = async (): Promise => { const operation = await Imports.findLastImport(); @@ -32,6 +33,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async getImportProgress() { + methodDeprecationLogger.method('getImportProgress', '9.0.0', '/v1/getImportProgress'); const userId = Meteor.userId(); if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', 'getImportProgress'); diff --git a/apps/meteor/app/importer/server/methods/getLatestImportOperations.ts b/apps/meteor/app/importer/server/methods/getLatestImportOperations.ts index 0471cb57955d5..602486070269f 100644 --- a/apps/meteor/app/importer/server/methods/getLatestImportOperations.ts +++ b/apps/meteor/app/importer/server/methods/getLatestImportOperations.ts @@ -4,6 +4,7 @@ import { Imports } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; export const executeGetLatestImportOperations = async () => { const data = Imports.find( @@ -26,6 +27,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async getLatestImportOperations() { + methodDeprecationLogger.method('getLatestImportOperations', '9.0.0', '/v1/getLatestImportOperations'); const userId = Meteor.userId(); if (!userId) { diff --git a/apps/meteor/app/importer/server/methods/startImport.ts b/apps/meteor/app/importer/server/methods/startImport.ts index d86638662f6f3..32fe5c393252c 100644 --- a/apps/meteor/app/importer/server/methods/startImport.ts +++ b/apps/meteor/app/importer/server/methods/startImport.ts @@ -6,6 +6,7 @@ import { Meteor } from 'meteor/meteor'; import { Importers } from '..'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; export const executeStartImport = async ({ input }: StartImportParamsPOST, startedByUserId: IUser['_id']) => { const operation = await Imports.findLastImport(); @@ -33,6 +34,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async startImport({ input }: StartImportParamsPOST) { + methodDeprecationLogger.method('startImport', '9.0.0', '/v1/startImport'); if (!input || typeof input !== 'object' || !isStartImportParamsPOST({ input })) { throw new Meteor.Error(`Invalid Selection data provided to the importer.`); } diff --git a/apps/meteor/app/importer/server/methods/uploadImportFile.ts b/apps/meteor/app/importer/server/methods/uploadImportFile.ts index df5d2af883fae..22f9493ca7dcb 100644 --- a/apps/meteor/app/importer/server/methods/uploadImportFile.ts +++ b/apps/meteor/app/importer/server/methods/uploadImportFile.ts @@ -6,6 +6,7 @@ import { Meteor } from 'meteor/meteor'; import { Importers } from '..'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { RocketChatFile } from '../../../file/server'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { ProgressStep } from '../../lib/ImporterProgressStep'; import { RocketChatImportFileInstance } from '../startup/store'; @@ -64,6 +65,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async uploadImportFile(binaryContent, contentType, fileName, importerKey) { + methodDeprecationLogger.method('uploadImportFile', '9.0.0', '/v1/uploadImportFile'); const userId = Meteor.userId(); if (!userId) { diff --git a/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts b/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts index 49116d1335ee8..95c617cb250fe 100644 --- a/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts +++ b/apps/meteor/app/integrations/server/methods/incoming/addIncomingIntegration.ts @@ -8,6 +8,7 @@ import { Meteor } from 'meteor/meteor'; import { addUserRolesAsync } from '../../../../../server/lib/roles/addUserRoles'; import { hasPermissionAsync, hasAllPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../../../lib/server/lib/deprecationWarningLogger'; import { notifyOnIntegrationChanged } from '../../../../lib/server/lib/notifyListener'; import { compileIntegrationScript } from '../../lib/compileIntegrationScript'; import { validateScriptEngine, isScriptEngineFrozen } from '../../lib/validateScriptEngine'; @@ -175,6 +176,7 @@ export const addIncomingIntegration = async (userId: string, integration: INewIn Meteor.methods({ async addIncomingIntegration(integration: INewIncomingIntegration): Promise { + methodDeprecationLogger.method('addIncomingIntegration', '9.0.0', '/v1/integrations.create'); const { userId } = this; if (!userId) { diff --git a/apps/meteor/app/integrations/server/methods/incoming/deleteIncomingIntegration.ts b/apps/meteor/app/integrations/server/methods/incoming/deleteIncomingIntegration.ts index 9a116f51bbafa..3258aeed8aebc 100644 --- a/apps/meteor/app/integrations/server/methods/incoming/deleteIncomingIntegration.ts +++ b/apps/meteor/app/integrations/server/methods/incoming/deleteIncomingIntegration.ts @@ -3,6 +3,7 @@ import { Integrations } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../../../lib/server/lib/deprecationWarningLogger'; import { notifyOnIntegrationChangedById } from '../../../../lib/server/lib/notifyListener'; declare module '@rocket.chat/ddp-client' { @@ -40,6 +41,7 @@ export const deleteIncomingIntegration = async (integrationId: string, userId: s Meteor.methods({ async deleteIncomingIntegration(integrationId) { + methodDeprecationLogger.method('deleteIncomingIntegration', '9.0.0', '/v1/integrations.remove'); const userId = Meteor.userId(); if (!userId) { throw new Meteor.Error('not_authorized', 'Unauthorized', { diff --git a/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts b/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts index 894b5b31bd405..ae6a6020bdb5e 100644 --- a/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts +++ b/apps/meteor/app/integrations/server/methods/incoming/updateIncomingIntegration.ts @@ -6,6 +6,7 @@ import { Meteor } from 'meteor/meteor'; import { addUserRolesAsync } from '../../../../../server/lib/roles/addUserRoles'; import { hasAllPermissionAsync, hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../../../lib/server/lib/deprecationWarningLogger'; import { notifyOnIntegrationChanged } from '../../../../lib/server/lib/notifyListener'; import { compileIntegrationScript } from '../../lib/compileIntegrationScript'; import { isScriptEngineFrozen, validateScriptEngine } from '../../lib/validateScriptEngine'; @@ -191,6 +192,7 @@ export const updateIncomingIntegration = async ( Meteor.methods({ async updateIncomingIntegration(integrationId, integration) { + methodDeprecationLogger.method('updateIncomingIntegration', '9.0.0', '/v1/integrations.update'); if (!this.userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'updateOutgoingIntegration', diff --git a/apps/meteor/app/integrations/server/methods/outgoing/addOutgoingIntegration.ts b/apps/meteor/app/integrations/server/methods/outgoing/addOutgoingIntegration.ts index 033e797a6d65a..c965a4c88d955 100644 --- a/apps/meteor/app/integrations/server/methods/outgoing/addOutgoingIntegration.ts +++ b/apps/meteor/app/integrations/server/methods/outgoing/addOutgoingIntegration.ts @@ -6,6 +6,7 @@ import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../../../lib/server/lib/deprecationWarningLogger'; import { notifyOnIntegrationChanged } from '../../../../lib/server/lib/notifyListener'; import { validateOutgoingIntegration } from '../../lib/validateOutgoingIntegration'; import { validateScriptEngine } from '../../lib/validateScriptEngine'; @@ -75,6 +76,7 @@ export const addOutgoingIntegration = async (userId: string, integration: INewOu Meteor.methods({ async addOutgoingIntegration(integration: INewOutgoingIntegration): Promise { + methodDeprecationLogger.method('addOutgoingIntegration', '9.0.0', '/v1/integrations.create'); const { userId } = this; if (!userId) { throw new Meteor.Error('Invalid User'); diff --git a/apps/meteor/app/integrations/server/methods/outgoing/deleteOutgoingIntegration.ts b/apps/meteor/app/integrations/server/methods/outgoing/deleteOutgoingIntegration.ts index 46d4c65159c07..b9e44d7d5adbd 100644 --- a/apps/meteor/app/integrations/server/methods/outgoing/deleteOutgoingIntegration.ts +++ b/apps/meteor/app/integrations/server/methods/outgoing/deleteOutgoingIntegration.ts @@ -3,6 +3,7 @@ import { Integrations, IntegrationHistory } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../../../lib/server/lib/deprecationWarningLogger'; import { notifyOnIntegrationChangedById } from '../../../../lib/server/lib/notifyListener'; declare module '@rocket.chat/ddp-client' { @@ -48,6 +49,7 @@ export const deleteOutgoingIntegration = async (integrationId: string, userId: s Meteor.methods({ async deleteOutgoingIntegration(integrationId) { + methodDeprecationLogger.method('deleteOutgoingIntegration', '9.0.0', '/v1/integrations.remove'); const userId = Meteor.userId(); if (!userId) { throw new Meteor.Error('not_authorized', 'Unauthorized', { diff --git a/apps/meteor/app/integrations/server/methods/outgoing/updateOutgoingIntegration.ts b/apps/meteor/app/integrations/server/methods/outgoing/updateOutgoingIntegration.ts index aee22f252efab..f76b70e1ba768 100644 --- a/apps/meteor/app/integrations/server/methods/outgoing/updateOutgoingIntegration.ts +++ b/apps/meteor/app/integrations/server/methods/outgoing/updateOutgoingIntegration.ts @@ -5,6 +5,7 @@ import { wrapExceptions } from '@rocket.chat/tools'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../../../lib/server/lib/deprecationWarningLogger'; import { notifyOnIntegrationChanged } from '../../../../lib/server/lib/notifyListener'; import { validateOutgoingIntegration } from '../../lib/validateOutgoingIntegration'; import { isScriptEngineFrozen, validateScriptEngine } from '../../lib/validateScriptEngine'; @@ -119,6 +120,7 @@ export const updateOutgoingIntegration = async ( Meteor.methods({ async updateOutgoingIntegration(integrationId, _integration) { + methodDeprecationLogger.method('updateOutgoingIntegration', '9.0.0', '/v1/integrations.update'); if (!this.userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'updateOutgoingIntegration', diff --git a/apps/meteor/app/lib/server/methods/addUserToRoom.ts b/apps/meteor/app/lib/server/methods/addUserToRoom.ts index 8100cafc33b00..4c23ae7494ad4 100644 --- a/apps/meteor/app/lib/server/methods/addUserToRoom.ts +++ b/apps/meteor/app/lib/server/methods/addUserToRoom.ts @@ -2,6 +2,7 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Meteor } from 'meteor/meteor'; import { addUsersToRoomMethod } from './addUsersToRoom'; +import { methodDeprecationLogger } from '../lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -12,6 +13,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async addUserToRoom(data) { + methodDeprecationLogger.method('addUserToRoom', '9.0.0', ['/v1/channels.invite', '/v1/groups.invite']); const userId = Meteor.userId(); if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { diff --git a/apps/meteor/app/lib/server/methods/archiveRoom.ts b/apps/meteor/app/lib/server/methods/archiveRoom.ts index 1ccc74ee644f5..5a7e989df175d 100644 --- a/apps/meteor/app/lib/server/methods/archiveRoom.ts +++ b/apps/meteor/app/lib/server/methods/archiveRoom.ts @@ -8,6 +8,7 @@ import { RoomMemberActions } from '../../../../definition/IRoomTypeConfig'; import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { archiveRoom } from '../functions/archiveRoom'; +import { methodDeprecationLogger } from '../lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -42,6 +43,7 @@ export const executeArchiveRoom = async (userId: string, rid: string) => { Meteor.methods({ async archiveRoom(rid) { + methodDeprecationLogger.method('archiveRoom', '9.0.0', '/v1/channels.archive'); const userId = Meteor.userId(); if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'archiveRoom' }); diff --git a/apps/meteor/app/lib/server/methods/createChannel.ts b/apps/meteor/app/lib/server/methods/createChannel.ts index 210dada149712..2180d4bc1d235 100644 --- a/apps/meteor/app/lib/server/methods/createChannel.ts +++ b/apps/meteor/app/lib/server/methods/createChannel.ts @@ -6,6 +6,7 @@ import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { createRoom } from '../functions/createRoom'; +import { methodDeprecationLogger } from '../lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -60,6 +61,7 @@ export const createChannelMethod = async ( Meteor.methods({ async createChannel(name, members, readOnly = false, customFields = {}, extraData = {}) { + methodDeprecationLogger.method('createChannel', '9.0.0', '/v1/channels.create'); const uid = Meteor.userId(); if (!uid) { diff --git a/apps/meteor/app/lib/server/methods/deleteUserOwnAccount.ts b/apps/meteor/app/lib/server/methods/deleteUserOwnAccount.ts index 478a7872ce0fe..25ad56575e278 100644 --- a/apps/meteor/app/lib/server/methods/deleteUserOwnAccount.ts +++ b/apps/meteor/app/lib/server/methods/deleteUserOwnAccount.ts @@ -9,6 +9,7 @@ import { Meteor } from 'meteor/meteor'; import { trim } from '../../../../lib/utils/stringUtils'; import { settings } from '../../../settings/server'; import { deleteUser } from '../functions/deleteUser'; +import { methodDeprecationLogger } from '../lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -63,6 +64,7 @@ export const deleteUserOwnAccount = async (fromUserId: string, password: string, Meteor.methods({ async deleteUserOwnAccount(password, confirmRelinquish) { + methodDeprecationLogger.method('deleteUserOwnAccount', '9.0.0', '/v1/users.deleteOwnAccount'); check(password, String); const uid = Meteor.userId(); diff --git a/apps/meteor/app/lib/server/methods/getChannelHistory.ts b/apps/meteor/app/lib/server/methods/getChannelHistory.ts index cd2096a82a1a9..a41fc82dc3d54 100644 --- a/apps/meteor/app/lib/server/methods/getChannelHistory.ts +++ b/apps/meteor/app/lib/server/methods/getChannelHistory.ts @@ -7,6 +7,7 @@ import { Meteor } from 'meteor/meteor'; import { settings } from '../../../settings/server/cached'; import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; +import { methodDeprecationLogger } from '../lib/deprecationWarningLogger'; import { getHiddenSystemMessages } from '../lib/getHiddenSystemMessages'; declare module '@rocket.chat/ddp-client' { @@ -155,6 +156,7 @@ export const getChannelHistory = async ({ Meteor.methods({ async getChannelHistory({ rid, latest, oldest, inclusive, offset = 0, count = 20, unreads, showThreadMessages = true }) { + methodDeprecationLogger.method('getChannelHistory', '9.0.0', '/v1/channels.history'); check(rid, String); if (!Meteor.userId()) { diff --git a/apps/meteor/app/lib/server/methods/getUsernameSuggestion.ts b/apps/meteor/app/lib/server/methods/getUsernameSuggestion.ts index b42799f240813..601f0e9564b71 100644 --- a/apps/meteor/app/lib/server/methods/getUsernameSuggestion.ts +++ b/apps/meteor/app/lib/server/methods/getUsernameSuggestion.ts @@ -2,6 +2,7 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Meteor } from 'meteor/meteor'; import { generateUsernameSuggestion } from '../functions/getUsernameSuggestion'; +import { methodDeprecationLogger } from '../lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -12,6 +13,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async getUsernameSuggestion() { + methodDeprecationLogger.method('getUsernameSuggestion', '9.0.0', '/v1/users.getUsernameSuggestion'); const user = await Meteor.userAsync(); if (!user) { diff --git a/apps/meteor/app/lib/server/methods/saveSetting.ts b/apps/meteor/app/lib/server/methods/saveSetting.ts index 044a618dacac0..5cbc8559ab10e 100644 --- a/apps/meteor/app/lib/server/methods/saveSetting.ts +++ b/apps/meteor/app/lib/server/methods/saveSetting.ts @@ -9,6 +9,7 @@ import { twoFactorRequired } from '../../../2fa/server/twoFactorRequired'; import { getSettingPermissionId } from '../../../authorization/lib'; import { hasPermissionAsync, hasAllPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { disableCustomScripts } from '../functions/disableCustomScripts'; +import { methodDeprecationLogger } from '../lib/deprecationWarningLogger'; import { notifyOnSettingChanged } from '../lib/notifyListener'; declare module '@rocket.chat/ddp-client' { @@ -20,6 +21,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ saveSetting: twoFactorRequired(async function (_id: string, value: SettingValue, editor: SettingEditor) { + methodDeprecationLogger.method('saveSetting', '9.0.0', '/v1/settings/:_id'); const uid = Meteor.userId(); if (!uid) { throw new Meteor.Error('error-action-not-allowed', 'Editing settings is not allowed', { @@ -74,9 +76,9 @@ Meteor.methods({ useragent: this.connection?.httpHeaders['user-agent'] || '', }); - (await auditSettingOperation(Settings.updateValueAndEditorById, _id, value as SettingValue, editor)).modifiedCount && + (await auditSettingOperation(Settings.updateValueAndEditorById, _id, value, editor)).modifiedCount && setting && - void notifyOnSettingChanged({ ...setting, editor, value: value as SettingValue }); + void notifyOnSettingChanged({ ...setting, editor, value }); return true; }), diff --git a/apps/meteor/app/lib/server/methods/saveSettings.ts b/apps/meteor/app/lib/server/methods/saveSettings.ts index 11458f2f0be78..e8efa3be6ebd1 100644 --- a/apps/meteor/app/lib/server/methods/saveSettings.ts +++ b/apps/meteor/app/lib/server/methods/saveSettings.ts @@ -130,8 +130,8 @@ Meteor.methods({ const auditSettingOperation = updateAuditedByUser({ _id: uid, username: (await Meteor.userAsync())!.username!, - ip: this.connection!.clientAddress || '', - useragent: this.connection!.httpHeaders['user-agent'] || '', + ip: this.connection.clientAddress || '', + useragent: this.connection.httpHeaders['user-agent'] || '', }); const promises = params.map(({ _id, value }) => auditSettingOperation(Settings.updateValueById, _id, value)); diff --git a/apps/meteor/app/lib/server/methods/sendMessage.ts b/apps/meteor/app/lib/server/methods/sendMessage.ts index 2f5094e138f88..cf5deabc68a66 100644 --- a/apps/meteor/app/lib/server/methods/sendMessage.ts +++ b/apps/meteor/app/lib/server/methods/sendMessage.ts @@ -18,7 +18,6 @@ import { metrics } from '../../../metrics/server'; import { settings } from '../../../settings/server'; import { sendMessage } from '../functions/sendMessage'; import { RateLimiter } from '../lib'; - /** * * @param uid diff --git a/apps/meteor/app/lib/server/methods/setEmail.ts b/apps/meteor/app/lib/server/methods/setEmail.ts index e1faece1e9919..67c59572631d1 100644 --- a/apps/meteor/app/lib/server/methods/setEmail.ts +++ b/apps/meteor/app/lib/server/methods/setEmail.ts @@ -6,6 +6,7 @@ import { Meteor } from 'meteor/meteor'; import { settings } from '../../../settings/server'; import { setEmail } from '../functions/setEmail'; import { RateLimiter } from '../lib'; +import { methodDeprecationLogger } from '../lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -24,7 +25,7 @@ export const setEmailFunction = async (email: string, user: Meteor.User | IUser) }); } - if (user.emails?.[0] && user.emails[0].address === email) { + if (user.emails?.[0]?.address === email) { return email; } @@ -39,6 +40,7 @@ export const setEmailFunction = async (email: string, user: Meteor.User | IUser) Meteor.methods({ async setEmail(email) { + methodDeprecationLogger.method('setEmail', '9.0.0', '/v1/users.updateOwnBasicInfo'); const user = await Meteor.userAsync(); if (!user) { diff --git a/apps/meteor/app/lib/server/methods/setRealName.ts b/apps/meteor/app/lib/server/methods/setRealName.ts index 2a1bac15b63bc..da72e1b6257bd 100644 --- a/apps/meteor/app/lib/server/methods/setRealName.ts +++ b/apps/meteor/app/lib/server/methods/setRealName.ts @@ -5,6 +5,7 @@ import { Meteor } from 'meteor/meteor'; import { settings } from '../../../settings/server'; import { setRealName } from '../functions/setRealName'; import { RateLimiter } from '../lib'; +import { methodDeprecationLogger } from '../lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -15,6 +16,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async setRealName(name) { + methodDeprecationLogger.method('setRealName', '9.0.0', '/v1/users.updateOwnBasicInfo'); check(name, String); const userId = Meteor.userId(); if (!userId) { diff --git a/apps/meteor/app/lib/server/methods/unarchiveRoom.ts b/apps/meteor/app/lib/server/methods/unarchiveRoom.ts index 1d28dc54bc2f4..389f0af6c8d5f 100644 --- a/apps/meteor/app/lib/server/methods/unarchiveRoom.ts +++ b/apps/meteor/app/lib/server/methods/unarchiveRoom.ts @@ -6,6 +6,7 @@ import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { unarchiveRoom } from '../functions/unarchiveRoom'; +import { methodDeprecationLogger } from '../lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -37,6 +38,7 @@ export const executeUnarchiveRoom = async (userId: string, rid: string) => { Meteor.methods({ async unarchiveRoom(rid) { + methodDeprecationLogger.method('unarchiveRoom', '9.0.0', '/v1/channels.unarchive'); const userId = Meteor.userId(); if (!userId) { diff --git a/apps/meteor/app/livechat/server/methods/sendMessageLivechat.ts b/apps/meteor/app/livechat/server/methods/sendMessageLivechat.ts index 67fcaecdcba42..434b99ebb4303 100644 --- a/apps/meteor/app/livechat/server/methods/sendMessageLivechat.ts +++ b/apps/meteor/app/livechat/server/methods/sendMessageLivechat.ts @@ -4,6 +4,7 @@ import { LivechatVisitors } from '@rocket.chat/models'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { settings } from '../../../settings/server'; import type { ILivechatMessage } from '../lib/localTypes'; import { sendMessage } from '../lib/messages'; @@ -81,6 +82,7 @@ export const sendMessageLivechat = async ({ Meteor.methods({ async sendMessageLivechat({ token, _id, rid, msg, file, files, attachments }: ILivechatMessage, agent: ILivechatMessageAgent) { + methodDeprecationLogger.method('sendMessageLivechat', '9.0.0', '/v1/livechat/message'); return sendMessageLivechat({ message: { token, _id, rid, msg, file, files, attachments }, agent }); }, }); diff --git a/apps/meteor/app/mentions/server/methods/getUserMentionsByChannel.ts b/apps/meteor/app/mentions/server/methods/getUserMentionsByChannel.ts index 31efd978fc01f..a9e5b97b81349 100644 --- a/apps/meteor/app/mentions/server/methods/getUserMentionsByChannel.ts +++ b/apps/meteor/app/mentions/server/methods/getUserMentionsByChannel.ts @@ -5,6 +5,7 @@ import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { canAccessRoomAsync } from '../../../authorization/server'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -38,6 +39,7 @@ export const getUserMentionsByChannel = async ( Meteor.methods({ async getUserMentionsByChannel({ roomId, options }) { + methodDeprecationLogger.method('getUserMentionsByChannel', '9.0.0', '/v1/channels.getAllUserMentionsByChannel'); const uid = Meteor.userId(); if (!uid) { diff --git a/apps/meteor/app/message-mark-as-unread/server/unreadMessages.ts b/apps/meteor/app/message-mark-as-unread/server/unreadMessages.ts index ccf972c561e31..cdb4f5d2a31cb 100644 --- a/apps/meteor/app/message-mark-as-unread/server/unreadMessages.ts +++ b/apps/meteor/app/message-mark-as-unread/server/unreadMessages.ts @@ -4,6 +4,7 @@ import { Messages, Subscriptions } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import logger from './logger'; +import { methodDeprecationLogger } from '../../lib/server/lib/deprecationWarningLogger'; import { notifyOnSubscriptionChangedByRoomIdAndUserId } from '../../lib/server/lib/notifyListener'; declare module '@rocket.chat/ddp-client' { @@ -81,6 +82,7 @@ export const unreadMessages = async (userId: string, firstUnreadMessage?: Pick({ async unreadMessages(firstUnreadMessage, room) { + methodDeprecationLogger.method('unreadMessages', '9.0.0', '/v1/subscriptions.unread'); const userId = Meteor.userId(); if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { diff --git a/apps/meteor/app/message-pin/server/pinMessage.ts b/apps/meteor/app/message-pin/server/pinMessage.ts index 58f295ec02991..2962f4680adad 100644 --- a/apps/meteor/app/message-pin/server/pinMessage.ts +++ b/apps/meteor/app/message-pin/server/pinMessage.ts @@ -11,6 +11,7 @@ import { Meteor } from 'meteor/meteor'; import { canAccessRoomAsync, roomAccessAttributes } from '../../authorization/server'; import { hasPermissionAsync } from '../../authorization/server/functions/hasPermission'; import { isTheLastMessage } from '../../lib/server/functions/isTheLastMessage'; +import { methodDeprecationLogger } from '../../lib/server/lib/deprecationWarningLogger'; import { notifyOnRoomChangedById, notifyOnMessageChange } from '../../lib/server/lib/notifyListener'; import { settings } from '../../settings/server'; import { getUserAvatarURL } from '../../utils/server/getUserAvatarURL'; @@ -198,6 +199,7 @@ export const unpinMessage = async (userId: string, message: IMessage) => { Meteor.methods({ async pinMessage(message, pinnedAt) { + methodDeprecationLogger.method('pinMessage', '9.0.0', '/v1/chat.pinMessage'); check(message._id, String); const userId = Meteor.userId(); @@ -210,6 +212,7 @@ Meteor.methods({ return pinMessage(message, userId, pinnedAt); }, async unpinMessage(message) { + methodDeprecationLogger.method('unpinMessage', '9.0.0', '/v1/chat.unPinMessage'); check(message._id, String); const userId = Meteor.userId(); diff --git a/apps/meteor/app/oauth2-server-config/server/admin/methods/deleteOAuthApp.ts b/apps/meteor/app/oauth2-server-config/server/admin/methods/deleteOAuthApp.ts index c123ce07072db..ea86151253ec4 100644 --- a/apps/meteor/app/oauth2-server-config/server/admin/methods/deleteOAuthApp.ts +++ b/apps/meteor/app/oauth2-server-config/server/admin/methods/deleteOAuthApp.ts @@ -4,6 +4,7 @@ import { OAuthAccessTokens, OAuthApps, OAuthAuthCodes } from '@rocket.chat/model import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../../../lib/server/lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -34,6 +35,7 @@ export const deleteOAuthApp = async (userId: string, applicationId: IOAuthApps[' Meteor.methods({ async deleteOAuthApp(applicationId) { + methodDeprecationLogger.method('deleteOAuthApp', '9.0.0', '/v1/oauth-apps.delete'); if (!this.userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'deleteOAuthApp' }); } diff --git a/apps/meteor/app/oauth2-server-config/server/admin/methods/updateOAuthApp.ts b/apps/meteor/app/oauth2-server-config/server/admin/methods/updateOAuthApp.ts index f6d2e26575e37..a5adfbaea4904 100644 --- a/apps/meteor/app/oauth2-server-config/server/admin/methods/updateOAuthApp.ts +++ b/apps/meteor/app/oauth2-server-config/server/admin/methods/updateOAuthApp.ts @@ -4,6 +4,7 @@ import { OAuthApps, Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../../../lib/server/lib/deprecationWarningLogger'; import { parseUriList } from '../functions/parseUriList'; declare module '@rocket.chat/ddp-client' { @@ -77,6 +78,7 @@ export const updateOAuthApp = async ( Meteor.methods({ async updateOAuthApp(applicationId, application) { + methodDeprecationLogger.method('updateOAuthApp', '9.0.0', '/v1/oauth-apps.update'); if (!this.userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'updateOAuthApp' }); } diff --git a/apps/meteor/app/push-notifications/server/methods/saveNotificationSettings.ts b/apps/meteor/app/push-notifications/server/methods/saveNotificationSettings.ts index ea2a2d4545192..1a737384acca7 100644 --- a/apps/meteor/app/push-notifications/server/methods/saveNotificationSettings.ts +++ b/apps/meteor/app/push-notifications/server/methods/saveNotificationSettings.ts @@ -4,6 +4,7 @@ import { Subscriptions } from '@rocket.chat/models'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { notifyOnSubscriptionChangedById } from '../../../lib/server/lib/notifyListener'; import { getUserNotificationPreference } from '../../../utils/server/getUserNotificationPreference'; @@ -132,6 +133,7 @@ export const saveNotificationSettingsMethod = async ( Meteor.methods({ async saveNotificationSettings(roomId, field, value) { + methodDeprecationLogger.method('saveNotificationSettings', '9.0.0', '/v1/rooms.saveNotification'); const userId = Meteor.userId(); if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { diff --git a/apps/meteor/app/push/server/methods.ts b/apps/meteor/app/push/server/methods.ts index e4dddc8910dfc..43514d0f14735 100644 --- a/apps/meteor/app/push/server/methods.ts +++ b/apps/meteor/app/push/server/methods.ts @@ -8,6 +8,7 @@ import { Meteor } from 'meteor/meteor'; import { logger } from './logger'; import { _matchToken } from './push'; +import { methodDeprecationLogger } from '../../lib/server/lib/deprecationWarningLogger'; type PushUpdateOptions = { id?: string; @@ -27,6 +28,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async 'raix:push-update'(options) { + methodDeprecationLogger.method('raix:push-update', '9.0.0', '/v1/push.token'); logger.debug({ msg: 'Got push token from app', options }); check(options, { diff --git a/apps/meteor/app/statistics/server/methods/getStatistics.ts b/apps/meteor/app/statistics/server/methods/getStatistics.ts index a804ef83584e3..1b383bdc54536 100644 --- a/apps/meteor/app/statistics/server/methods/getStatistics.ts +++ b/apps/meteor/app/statistics/server/methods/getStatistics.ts @@ -2,6 +2,7 @@ import type { IStats } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { getLastStatistics } from '../functions/getLastStatistics'; declare module '@rocket.chat/ddp-client' { @@ -13,6 +14,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async getStatistics(refresh) { + methodDeprecationLogger.method('getStatistics', '9.0.0', '/v1/statistics'); const uid = Meteor.userId(); if (!uid) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getStatistics' }); diff --git a/apps/meteor/app/threads/server/methods/followMessage.ts b/apps/meteor/app/threads/server/methods/followMessage.ts index ef32595e930a0..c8477473d771d 100644 --- a/apps/meteor/app/threads/server/methods/followMessage.ts +++ b/apps/meteor/app/threads/server/methods/followMessage.ts @@ -7,6 +7,7 @@ import { Meteor } from 'meteor/meteor'; import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; import { RateLimiter } from '../../../lib/server'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { notifyOnMessageChange } from '../../../lib/server/lib/notifyListener'; import { settings } from '../../../settings/server'; import { follow } from '../functions'; @@ -50,6 +51,7 @@ export const followMessage = async (user: IUser, { mid }: { mid: IMessage['_id'] Meteor.methods({ async followMessage({ mid }) { + methodDeprecationLogger.method('followMessage', '9.0.0', '/v1/chat.followMessage'); check(mid, String); const user = (await Meteor.userAsync()) as IUser; diff --git a/apps/meteor/app/threads/server/methods/getThreadsList.ts b/apps/meteor/app/threads/server/methods/getThreadsList.ts index c51449ff2d4d3..f9a51fa9cd79e 100644 --- a/apps/meteor/app/threads/server/methods/getThreadsList.ts +++ b/apps/meteor/app/threads/server/methods/getThreadsList.ts @@ -4,6 +4,7 @@ import { Messages, Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { canAccessRoomAsync } from '../../../authorization/server'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { settings } from '../../../settings/server'; const MAX_LIMIT = 100; @@ -17,6 +18,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async getThreadsList({ rid, limit = 50, skip = 0 }) { + methodDeprecationLogger.method('getThreadsList', '9.0.0', '/v1/chat.getThreadsList'); if (limit > MAX_LIMIT) { throw new Meteor.Error('error-not-allowed', `max limit: ${MAX_LIMIT}`, { method: 'getThreadsList', diff --git a/apps/meteor/app/threads/server/methods/unfollowMessage.ts b/apps/meteor/app/threads/server/methods/unfollowMessage.ts index f5ffaa19fc7af..e1d48869254ed 100644 --- a/apps/meteor/app/threads/server/methods/unfollowMessage.ts +++ b/apps/meteor/app/threads/server/methods/unfollowMessage.ts @@ -7,6 +7,7 @@ import { Meteor } from 'meteor/meteor'; import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; import { RateLimiter } from '../../../lib/server'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { notifyOnMessageChange } from '../../../lib/server/lib/notifyListener'; import { settings } from '../../../settings/server'; import { unfollow } from '../functions'; @@ -50,6 +51,7 @@ export const unfollowMessage = async (user: IUser, { mid }: { mid: IMessage['_id Meteor.methods({ async unfollowMessage({ mid }) { + methodDeprecationLogger.method('unfollowMessage', '9.0.0', '/v1/chat.unfollowMessage'); check(mid, String); const user = (await Meteor.userAsync()) as IUser; diff --git a/apps/meteor/app/user-status/server/methods/deleteCustomUserStatus.ts b/apps/meteor/app/user-status/server/methods/deleteCustomUserStatus.ts index 72ccb9ccbd07d..7592e99413d30 100644 --- a/apps/meteor/app/user-status/server/methods/deleteCustomUserStatus.ts +++ b/apps/meteor/app/user-status/server/methods/deleteCustomUserStatus.ts @@ -4,6 +4,7 @@ import { CustomUserStatus } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -30,6 +31,7 @@ export const deleteCustomUserStatus = async (userId: string, userStatusID: strin Meteor.methods({ async deleteCustomUserStatus(userStatusID) { + methodDeprecationLogger.method('deleteCustomUserStatus', '9.0.0', '/v1/custom-user-status.delete'); if (!this.userId) { throw new Meteor.Error('not_authorized'); } diff --git a/apps/meteor/app/user-status/server/methods/getUserStatusText.ts b/apps/meteor/app/user-status/server/methods/getUserStatusText.ts index 911a69854b955..fa22fdde45eea 100644 --- a/apps/meteor/app/user-status/server/methods/getUserStatusText.ts +++ b/apps/meteor/app/user-status/server/methods/getUserStatusText.ts @@ -2,6 +2,7 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Meteor } from 'meteor/meteor'; import { getStatusText } from '../../../lib/server/functions/getStatusText'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -12,6 +13,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async getUserStatusText(userId) { + methodDeprecationLogger.method('getUserStatusText', '9.0.0', '/v1/users.presence'); const currentUserId = Meteor.userId(); if (!currentUserId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getUserStatusText' }); diff --git a/apps/meteor/app/user-status/server/methods/insertOrUpdateUserStatus.ts b/apps/meteor/app/user-status/server/methods/insertOrUpdateUserStatus.ts index 16f8dc716b06a..2d3477a02fcd6 100644 --- a/apps/meteor/app/user-status/server/methods/insertOrUpdateUserStatus.ts +++ b/apps/meteor/app/user-status/server/methods/insertOrUpdateUserStatus.ts @@ -7,6 +7,7 @@ import { Meteor } from 'meteor/meteor'; import { trim } from '../../../../lib/utils/stringUtils'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; type InsertOrUpdateUserStatus = { _id?: string; @@ -104,6 +105,7 @@ export const insertOrUpdateUserStatus = async (userId: string, userStatusData: I Meteor.methods({ async insertOrUpdateUserStatus(userStatusData) { + methodDeprecationLogger.method('insertOrUpdateUserStatus', '9.0.0', ['/v1/custom-user-status.create', '/v1/custom-user-status.update']); if (!this.userId) { throw new Meteor.Error('not_authorized'); } diff --git a/apps/meteor/ee/app/license/server/methods.ts b/apps/meteor/ee/app/license/server/methods.ts index 9546e834c2e16..31a9638aa6158 100644 --- a/apps/meteor/ee/app/license/server/methods.ts +++ b/apps/meteor/ee/app/license/server/methods.ts @@ -4,6 +4,8 @@ import { License } from '@rocket.chat/license'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../../../../app/lib/server/lib/deprecationWarningLogger'; + declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { @@ -16,6 +18,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ 'license:hasLicense'(feature: string) { + methodDeprecationLogger.method('license:hasLicense', '9.0.0', '/v1/licenses.info'); check(feature, String); return License.hasModule(feature as LicenseModule); @@ -24,6 +27,7 @@ Meteor.methods({ return License.getModules(); }, 'license:getTags'() { + methodDeprecationLogger.method('license:getTags', '9.0.0', '/v1/licenses.info'); return License.getTags(); }, 'license:isEnterprise'() { diff --git a/apps/meteor/imports/personal-access-tokens/server/api/methods/generateToken.ts b/apps/meteor/imports/personal-access-tokens/server/api/methods/generateToken.ts index fdb6067e2eb5d..3035cfffbbd98 100644 --- a/apps/meteor/imports/personal-access-tokens/server/api/methods/generateToken.ts +++ b/apps/meteor/imports/personal-access-tokens/server/api/methods/generateToken.ts @@ -6,7 +6,6 @@ import { Users } from '@rocket.chat/models'; import { hasPermissionAsync } from '../../../../../app/authorization/server/functions/hasPermission'; import { twoFactorRequired } from '../../../../../app/2fa/server/twoFactorRequired'; - declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { diff --git a/apps/meteor/imports/personal-access-tokens/server/api/methods/regenerateToken.ts b/apps/meteor/imports/personal-access-tokens/server/api/methods/regenerateToken.ts index 046929a1ad97a..b0c39a11a6253 100644 --- a/apps/meteor/imports/personal-access-tokens/server/api/methods/regenerateToken.ts +++ b/apps/meteor/imports/personal-access-tokens/server/api/methods/regenerateToken.ts @@ -7,7 +7,6 @@ import { hasPermissionAsync } from '../../../../../app/authorization/server/func import { twoFactorRequired } from '../../../../../app/2fa/server/twoFactorRequired'; import { removePersonalAccessTokenOfUser } from './removeToken'; import { generatePersonalAccessTokenOfUser } from './generateToken'; - declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { diff --git a/apps/meteor/imports/personal-access-tokens/server/api/methods/removeToken.ts b/apps/meteor/imports/personal-access-tokens/server/api/methods/removeToken.ts index 53048574d8127..dc73829daf973 100644 --- a/apps/meteor/imports/personal-access-tokens/server/api/methods/removeToken.ts +++ b/apps/meteor/imports/personal-access-tokens/server/api/methods/removeToken.ts @@ -4,7 +4,6 @@ import { Users } from '@rocket.chat/models'; import { hasPermissionAsync } from '../../../../../app/authorization/server/functions/hasPermission'; import { twoFactorRequired } from '../../../../../app/2fa/server/twoFactorRequired'; - declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { diff --git a/apps/meteor/server/methods/addAllUserToRoom.ts b/apps/meteor/server/methods/addAllUserToRoom.ts index 57f9ea3600b39..dcb20c317dd68 100644 --- a/apps/meteor/server/methods/addAllUserToRoom.ts +++ b/apps/meteor/server/methods/addAllUserToRoom.ts @@ -7,6 +7,7 @@ import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; import { beforeAddUserToRoom } from '../../app/lib/server/lib/beforeAddUserToRoom'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; import { notifyOnSubscriptionChangedById } from '../../app/lib/server/lib/notifyListener'; import { settings } from '../../app/settings/server'; import { getDefaultSubscriptionPref } from '../../app/utils/lib/getDefaultSubscriptionPref'; @@ -85,6 +86,7 @@ export const addAllUserToRoomFn = async (userId: string, rid: IRoom['_id'], acti Meteor.methods({ async addAllUserToRoom(rid, activeUsersOnly = false) { + methodDeprecationLogger.method('addAllUserToRoom', '9.0.0', ['/v1/channels.addAll', '/v1/groups.addAll']); if (!this.userId) { throw new Meteor.Error(403, 'Access to Method Forbidden', { method: 'addAllToRoom', diff --git a/apps/meteor/server/methods/addRoomLeader.ts b/apps/meteor/server/methods/addRoomLeader.ts index 54a2fed657b50..08b12c2d608a8 100644 --- a/apps/meteor/server/methods/addRoomLeader.ts +++ b/apps/meteor/server/methods/addRoomLeader.ts @@ -6,6 +6,7 @@ import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; import { notifyOnSubscriptionChangedById } from '../../app/lib/server/lib/notifyListener'; import { settings } from '../../app/settings/server'; import { syncRoomRolePriorityForUserAndRoom } from '../lib/roles/syncRoomRolePriority'; @@ -88,6 +89,7 @@ export const addRoomLeader = async (fromUserId: IUser['_id'], rid: IRoom['_id'], Meteor.methods({ async addRoomLeader(rid, userId) { + methodDeprecationLogger.method('addRoomLeader', '9.0.0', ['/v1/channels.addLeader', '/v1/groups.addLeader']); const uid = Meteor.userId(); if (!uid) { diff --git a/apps/meteor/server/methods/addRoomModerator.ts b/apps/meteor/server/methods/addRoomModerator.ts index 505942180396b..25888132d7243 100644 --- a/apps/meteor/server/methods/addRoomModerator.ts +++ b/apps/meteor/server/methods/addRoomModerator.ts @@ -7,6 +7,7 @@ import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; import { notifyOnSubscriptionChangedById } from '../../app/lib/server/lib/notifyListener'; import { settings } from '../../app/settings/server'; import { beforeChangeRoomRole } from '../lib/callbacks/beforeChangeRoomRole'; @@ -110,6 +111,7 @@ export const addRoomModerator = async (fromUserId: IUser['_id'], rid: IRoom['_id Meteor.methods({ async addRoomModerator(rid, userId) { + methodDeprecationLogger.method('addRoomModerator', '9.0.0', ['/v1/channels.addModerator', '/v1/groups.addModerator']); const uid = Meteor.userId(); if (!uid) { diff --git a/apps/meteor/server/methods/addRoomOwner.ts b/apps/meteor/server/methods/addRoomOwner.ts index 5c1eb3a3ae6b1..b9a38133442a3 100644 --- a/apps/meteor/server/methods/addRoomOwner.ts +++ b/apps/meteor/server/methods/addRoomOwner.ts @@ -7,6 +7,7 @@ import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; import { notifyOnSubscriptionChangedById } from '../../app/lib/server/lib/notifyListener'; import { settings } from '../../app/settings/server'; import { beforeChangeRoomRole } from '../lib/callbacks/beforeChangeRoomRole'; @@ -107,6 +108,7 @@ export const addRoomOwner = async (fromUserId: IUser['_id'], rid: IRoom['_id'], Meteor.methods({ async addRoomOwner(rid, userId) { + methodDeprecationLogger.method('addRoomOwner', '9.0.0', ['/v1/channels.addOwner', '/v1/groups.addOwner']); const uid = Meteor.userId(); if (!uid) { diff --git a/apps/meteor/server/methods/browseChannels.ts b/apps/meteor/server/methods/browseChannels.ts index b859bf52703d1..6640b45e5c2d8 100644 --- a/apps/meteor/server/methods/browseChannels.ts +++ b/apps/meteor/server/methods/browseChannels.ts @@ -10,6 +10,7 @@ import { Meteor } from 'meteor/meteor'; import type { FindOptions, SortDirection } from 'mongodb'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; import { settings } from '../../app/settings/server'; import { trim } from '../../lib/utils/stringUtils'; @@ -346,6 +347,7 @@ export const browseChannelsMethod = async ( Meteor.methods({ async browseChannels(params: BrowseChannelsParams) { + methodDeprecationLogger.method('browseChannels', '9.0.0', '/v1/directory'); return browseChannelsMethod(params, (await Meteor.userAsync()) as IUser | null); }, }); diff --git a/apps/meteor/server/methods/channelsList.ts b/apps/meteor/server/methods/channelsList.ts index 654c4aa070d6e..3512abbe3c21d 100644 --- a/apps/meteor/server/methods/channelsList.ts +++ b/apps/meteor/server/methods/channelsList.ts @@ -7,6 +7,7 @@ import type { FindOptions } from 'mongodb'; import _ from 'underscore'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; import { settings } from '../../app/settings/server'; import { getUserPreference } from '../../app/utils/server/lib/getUserPreference'; import { trim } from '../../lib/utils/stringUtils'; @@ -20,6 +21,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async channelsList(filter, channelType, limit, sort) { + methodDeprecationLogger.method('channelsList', '9.0.0', '/v1/channels.list'); check(filter, String); check(channelType, String); check(limit, Match.Optional(Number)); diff --git a/apps/meteor/server/methods/deleteUser.ts b/apps/meteor/server/methods/deleteUser.ts index ac542f12fa35c..5d2b569c46aa8 100644 --- a/apps/meteor/server/methods/deleteUser.ts +++ b/apps/meteor/server/methods/deleteUser.ts @@ -6,6 +6,7 @@ import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; import { deleteUser } from '../../app/lib/server/functions/deleteUser'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -46,6 +47,7 @@ export const executeDeleteUser = async (fromUserId: IUser['_id'], userId: IUser[ Meteor.methods({ async deleteUser(userId, confirmRelinquish = false) { + methodDeprecationLogger.method('deleteUser', '9.0.0', '/v1/users.delete'); check(userId, String); const uid = Meteor.userId(); diff --git a/apps/meteor/server/methods/getRoomIdByNameOrId.ts b/apps/meteor/server/methods/getRoomIdByNameOrId.ts index 91dbf555fe79f..b9e0c0620f6c2 100644 --- a/apps/meteor/server/methods/getRoomIdByNameOrId.ts +++ b/apps/meteor/server/methods/getRoomIdByNameOrId.ts @@ -5,6 +5,7 @@ import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { canAccessRoomAsync } from '../../app/authorization/server'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -15,6 +16,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async getRoomIdByNameOrId(rid) { + methodDeprecationLogger.method('getRoomIdByNameOrId', '9.0.0', '/v1/rooms.info'); check(rid, String); if (!Meteor.userId()) { diff --git a/apps/meteor/server/methods/getRoomNameById.ts b/apps/meteor/server/methods/getRoomNameById.ts index ebe0ade89f44c..abc446f7e4776 100644 --- a/apps/meteor/server/methods/getRoomNameById.ts +++ b/apps/meteor/server/methods/getRoomNameById.ts @@ -5,6 +5,7 @@ import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -15,6 +16,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async getRoomNameById(rid) { + methodDeprecationLogger.method('getRoomNameById', '9.0.0', '/v1/rooms.info'); check(rid, String); const userId = Meteor.userId(); if (!userId) { diff --git a/apps/meteor/server/methods/getTotalChannels.ts b/apps/meteor/server/methods/getTotalChannels.ts index 0a73f06a1be9c..8cb4a9e5abc49 100644 --- a/apps/meteor/server/methods/getTotalChannels.ts +++ b/apps/meteor/server/methods/getTotalChannels.ts @@ -2,6 +2,8 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; + declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { @@ -11,6 +13,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ getTotalChannels() { + methodDeprecationLogger.method('getTotalChannels', '9.0.0', '/v1/channels.list'); if (!Meteor.userId()) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getTotalChannels', diff --git a/apps/meteor/server/methods/hideRoom.ts b/apps/meteor/server/methods/hideRoom.ts index 1fd4c6b4657e3..9f89625b7008b 100644 --- a/apps/meteor/server/methods/hideRoom.ts +++ b/apps/meteor/server/methods/hideRoom.ts @@ -3,6 +3,7 @@ import { Subscriptions } from '@rocket.chat/models'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; import { notifyOnSubscriptionChangedByRoomIdAndUserId } from '../../app/lib/server/lib/notifyListener'; declare module '@rocket.chat/ddp-client' { @@ -32,6 +33,7 @@ export const hideRoomMethod = async (userId: string, rid: string): Promise({ async hideRoom(rid) { + methodDeprecationLogger.method('hideRoom', '9.0.0', '/v1/rooms.hide'); const uid = Meteor.userId(); if (!uid) { diff --git a/apps/meteor/server/methods/ignoreUser.ts b/apps/meteor/server/methods/ignoreUser.ts index 8e679221396a5..caf5ae7f30e45 100644 --- a/apps/meteor/server/methods/ignoreUser.ts +++ b/apps/meteor/server/methods/ignoreUser.ts @@ -3,6 +3,7 @@ import { Subscriptions } from '@rocket.chat/models'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; import { notifyOnSubscriptionChangedById } from '../../app/lib/server/lib/notifyListener'; declare module '@rocket.chat/ddp-client' { @@ -44,6 +45,7 @@ export const ignoreUser = async ( Meteor.methods({ async ignoreUser({ rid, userId: ignoredUser, ignore = true }) { + methodDeprecationLogger.method('ignoreUser', '9.0.0', '/v1/chat.ignoreUser'); check(ignoredUser, String); check(rid, String); check(ignore, Boolean); diff --git a/apps/meteor/server/methods/messageSearch.ts b/apps/meteor/server/methods/messageSearch.ts index 231f952192b92..016993f4c9303 100644 --- a/apps/meteor/server/methods/messageSearch.ts +++ b/apps/meteor/server/methods/messageSearch.ts @@ -5,6 +5,7 @@ import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { canAccessRoomIdAsync } from '../../app/authorization/server/functions/canAccessRoom'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; import type { IRawSearchResult } from '../../app/search/server/model/ISearchResult'; import { settings } from '../../app/settings/server'; import { readSecondaryPreferred } from '../database/readSecondaryPreferred'; @@ -87,6 +88,7 @@ export const messageSearch = async function ( Meteor.methods({ async messageSearch(text, rid, limit, offset) { + methodDeprecationLogger.method('messageSearch', '9.0.0', '/v1/chat.search'); const currentUserId = Meteor.userId(); if (!currentUserId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { diff --git a/apps/meteor/server/methods/removeRoomLeader.ts b/apps/meteor/server/methods/removeRoomLeader.ts index 9889437494742..b2158c994d883 100644 --- a/apps/meteor/server/methods/removeRoomLeader.ts +++ b/apps/meteor/server/methods/removeRoomLeader.ts @@ -6,6 +6,7 @@ import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; import { notifyOnSubscriptionChangedById } from '../../app/lib/server/lib/notifyListener'; import { settings } from '../../app/settings/server'; import { syncRoomRolePriorityForUserAndRoom } from '../lib/roles/syncRoomRolePriority'; @@ -88,6 +89,7 @@ export const removeRoomLeader = async (fromUserId: IUser['_id'], rid: IRoom['_id Meteor.methods({ async removeRoomLeader(rid, userId) { + methodDeprecationLogger.method('removeRoomLeader', '9.0.0', ['/v1/channels.removeLeader', '/v1/groups.removeLeader']); const uid = Meteor.userId(); if (!uid) { diff --git a/apps/meteor/server/methods/removeRoomModerator.ts b/apps/meteor/server/methods/removeRoomModerator.ts index 679cf24398d76..e38d60371de9d 100644 --- a/apps/meteor/server/methods/removeRoomModerator.ts +++ b/apps/meteor/server/methods/removeRoomModerator.ts @@ -7,6 +7,7 @@ import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; import { notifyOnSubscriptionChangedById } from '../../app/lib/server/lib/notifyListener'; import { settings } from '../../app/settings/server'; import { beforeChangeRoomRole } from '../lib/callbacks/beforeChangeRoomRole'; @@ -101,6 +102,7 @@ export const removeRoomModerator = async (fromUserId: IUser['_id'], rid: IRoom[' Meteor.methods({ async removeRoomModerator(rid, userId) { + methodDeprecationLogger.method('removeRoomModerator', '9.0.0', ['/v1/channels.removeModerator', '/v1/groups.removeModerator']); const uid = Meteor.userId(); if (!uid) { diff --git a/apps/meteor/server/methods/removeRoomOwner.ts b/apps/meteor/server/methods/removeRoomOwner.ts index 47cef84f475a2..3a1bda9c9c451 100644 --- a/apps/meteor/server/methods/removeRoomOwner.ts +++ b/apps/meteor/server/methods/removeRoomOwner.ts @@ -6,6 +6,7 @@ import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; import { notifyOnSubscriptionChangedById } from '../../app/lib/server/lib/notifyListener'; import { settings } from '../../app/settings/server'; import { beforeChangeRoomRole } from '../lib/callbacks/beforeChangeRoomRole'; @@ -106,6 +107,7 @@ export const removeRoomOwner = async (fromUserId: string, rid: string, userId: s Meteor.methods({ async removeRoomOwner(rid, userId) { + methodDeprecationLogger.method('removeRoomOwner', '9.0.0', ['/v1/channels.removeOwner', '/v1/groups.removeOwner']); const uid = Meteor.userId(); if (!uid) { diff --git a/apps/meteor/server/methods/removeUserFromRoom.ts b/apps/meteor/server/methods/removeUserFromRoom.ts index bf564e157e539..dccec9bceaac3 100644 --- a/apps/meteor/server/methods/removeUserFromRoom.ts +++ b/apps/meteor/server/methods/removeUserFromRoom.ts @@ -9,6 +9,7 @@ import { Meteor } from 'meteor/meteor'; import { canAccessRoomAsync } from '../../app/authorization/server'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; import { hasRoleAsync } from '../../app/authorization/server/functions/hasRole'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; import { notifyOnRoomChanged, notifyOnSubscriptionChanged } from '../../app/lib/server/lib/notifyListener'; import { settings } from '../../app/settings/server'; import { RoomMemberActions } from '../../definition/IRoomTypeConfig'; @@ -128,6 +129,7 @@ export const removeUserFromRoomMethod = async (fromId: string, data: { rid: stri Meteor.methods({ async removeUserFromRoom(data) { + methodDeprecationLogger.method('removeUserFromRoom', '9.0.0', ['/v1/channels.kick', '/v1/groups.kick']); check( data, Match.ObjectIncluding({ diff --git a/apps/meteor/server/methods/resetAvatar.ts b/apps/meteor/server/methods/resetAvatar.ts index 120458cc4335d..eed9bc7ad7689 100644 --- a/apps/meteor/server/methods/resetAvatar.ts +++ b/apps/meteor/server/methods/resetAvatar.ts @@ -6,6 +6,7 @@ import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; import { Meteor } from 'meteor/meteor'; import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; import { settings } from '../../app/settings/server'; declare module '@rocket.chat/ddp-client' { @@ -49,6 +50,7 @@ export const resetAvatar = async (fromUserId: IUser['_id'], userId: IUser['_id'] Meteor.methods({ async resetAvatar(userId) { + methodDeprecationLogger.method('resetAvatar', '9.0.0', '/v1/users.resetAvatar'); const uid = Meteor.userId(); if (!uid) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { diff --git a/apps/meteor/server/methods/saveUserPreferences.ts b/apps/meteor/server/methods/saveUserPreferences.ts index 9f35abfc14408..d7582df642a34 100644 --- a/apps/meteor/server/methods/saveUserPreferences.ts +++ b/apps/meteor/server/methods/saveUserPreferences.ts @@ -5,6 +5,7 @@ import type { FontSize } from '@rocket.chat/rest-typings'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; import { notifyOnSubscriptionChangedByAutoTranslateAndUserId, notifyOnSubscriptionChangedByUserId, @@ -221,6 +222,7 @@ export const saveUserPreferences = async (settings: Partial, us Meteor.methods({ async saveUserPreferences(settings) { + methodDeprecationLogger.method('saveUserPreferences', '9.0.0', '/v1/users.setPreferences'); const userId = Meteor.userId(); if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'saveUserPreferences' }); diff --git a/apps/meteor/server/methods/toggleFavorite.ts b/apps/meteor/server/methods/toggleFavorite.ts index 251637253fb30..4bc676fd22c43 100644 --- a/apps/meteor/server/methods/toggleFavorite.ts +++ b/apps/meteor/server/methods/toggleFavorite.ts @@ -4,6 +4,7 @@ import { Subscriptions } from '@rocket.chat/models'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; import { notifyOnSubscriptionChangedByRoomIdAndUserId } from '../../app/lib/server/lib/notifyListener'; declare module '@rocket.chat/ddp-client' { @@ -30,6 +31,7 @@ export const toggleFavoriteMethod = async (userId: string, rid: IRoom['_id'], fa Meteor.methods({ async toggleFavorite(rid, favorite) { + methodDeprecationLogger.method('toggleFavorite', '9.0.0', '/v1/rooms.favorite'); check(rid, String); check(favorite, Match.Optional(Boolean)); const userId = Meteor.userId(); diff --git a/apps/meteor/server/methods/userPresence.ts b/apps/meteor/server/methods/userPresence.ts index 21f3f795155ea..28c5838c06f66 100644 --- a/apps/meteor/server/methods/userPresence.ts +++ b/apps/meteor/server/methods/userPresence.ts @@ -3,6 +3,8 @@ import { UserStatus } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; + declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { @@ -14,6 +16,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ 'UserPresence:setDefaultStatus'(status) { + methodDeprecationLogger.method('UserPresence:setDefaultStatus', '9.0.0', '/v1/users.setStatus'); const { userId } = this; if (!userId) { return; From b8ada2f0d717bfcb4bab21361bc116c8278c8daa Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 26 May 2026 13:28:16 -0600 Subject: [PATCH 017/161] chore: Use `authz.v2` for `GetDecision` (#40693) --- apps/meteor/tests/data/mock-server.helper.ts | 4 -- apps/meteor/tests/end-to-end/api/abac.ts | 5 +- ee/packages/abac/src/pdp/VirtruPDP.ts | 50 ++++++++------------ ee/packages/abac/src/pdp/types.ts | 18 ------- 4 files changed, 23 insertions(+), 54 deletions(-) diff --git a/apps/meteor/tests/data/mock-server.helper.ts b/apps/meteor/tests/data/mock-server.helper.ts index f5f06b0557aa7..8a3c3077c636a 100644 --- a/apps/meteor/tests/data/mock-server.helper.ts +++ b/apps/meteor/tests/data/mock-server.helper.ts @@ -64,10 +64,6 @@ export const seedDefaultMocks = async () => { ]); }; -export const seedGetDecisions = async (decision: Decision, times = 0) => { - await mockServerSet('POST', '/authorization.AuthorizationService/GetDecisions', { decisionResponses: [{ decision }] }, 200, times); -}; - export const seedGetDecisionBulk = async ( responses: Array<{ resourceDecisions: Array<{ decision: Decision; ephemeralResourceId?: string }> }>, times = 0, diff --git a/apps/meteor/tests/end-to-end/api/abac.ts b/apps/meteor/tests/end-to-end/api/abac.ts index c59ff933fb4a6..2431bf4e7329b 100644 --- a/apps/meteor/tests/end-to-end/api/abac.ts +++ b/apps/meteor/tests/end-to-end/api/abac.ts @@ -14,7 +14,6 @@ import { seedBulkDecisionByEntity, seedDefaultMocks, seedGetDecisionBulk, - seedGetDecisions, } from '../../data/mock-server.helper'; import { updatePermission, updateSetting } from '../../data/permissions.helper'; import { createRoom, deleteRoom } from '../../data/rooms.helper'; @@ -3108,7 +3107,7 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I it('user can access room history when PDP returns PERMIT', async () => { await mockServerReset(); await seedDefaultMocks(); - await seedGetDecisions('DECISION_PERMIT'); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }]); await request .get('/api/v1/groups.history') @@ -3162,7 +3161,7 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I it('user loses access when PDP flips to DENY', async () => { await mockServerReset(); await seedDefaultMocks(); - await seedGetDecisions('DECISION_DENY'); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_DENY', ephemeralResourceId: room._id }] }]); await request .get('/api/v1/groups.history') diff --git a/ee/packages/abac/src/pdp/VirtruPDP.ts b/ee/packages/abac/src/pdp/VirtruPDP.ts index 457d30d822979..e6cc185f0f142 100644 --- a/ee/packages/abac/src/pdp/VirtruPDP.ts +++ b/ee/packages/abac/src/pdp/VirtruPDP.ts @@ -6,12 +6,9 @@ import pLimit from 'p-limit'; import { OnlyCompliantCanBeAddedToRoomError, PdpHealthCheckError } from '../errors'; import { logger } from '../logger'; import type { - Decision, IEntityIdentifier, IPolicyDecisionPoint, - IGetDecisionRequest, IGetDecisionBulkRequest, - IGetDecisionsResponse, IGetDecisionBulkResponse, IResourceDecision, ITokenCache, @@ -105,11 +102,13 @@ export class VirtruPDP implements IPolicyDecisionPoint { private async checkAuthorizedAccess(token: string): Promise { try { - const response = await serverFetch(`${this.config.baseUrl}/authorization.AuthorizationService/GetDecisions`, { + const response = await serverFetch(`${this.config.baseUrl}/authorization.v2.AuthorizationService/GetEntitlements`, { method: 'POST', timeout: HEALTH_CHECK_TIMEOUT, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, - body: JSON.stringify({ decisionRequests: [] }), + body: JSON.stringify({ + entityIdentifier: { entityChain: { entities: [{ id: this.config.clientId }] } }, + }), // SECURITY: This can only be configured by users with enough privileges. It's ok to disable this check here. ignoreSsrfValidation: true, }); @@ -181,16 +180,6 @@ export class VirtruPDP implements IPolicyDecisionPoint { return response.json() as Promise; } - private async getDecision(request: IGetDecisionRequest): Promise { - const result = await this.apiCall('/authorization.AuthorizationService/GetDecisions', { - decisionRequests: [request], - }); - - pdpLogger.debug({ msg: 'GetDecision response', result: result.decisionResponses }); - - return result.decisionResponses?.[0]?.decision; - } - private async getDecisionBulk( requests: Array, ): Promise> { @@ -275,21 +264,24 @@ export class VirtruPDP implements IPolicyDecisionPoint { return { granted: false }; } - const decision = await this.getDecision({ - actions: [{ standard: 1 }], - resourceAttributes: [ - { - resourceAttributesId: room._id, - attributeValueFqns: this.buildAttributeFqns(attributes), - }, - ], - entityChains: [ - { - id: 'rc-access-check', - entities: [this.buildEntityIdentifier(entityKey)], + const responses = await this.getDecisionBulk([ + { + entityIdentifier: { + entityChain: { + entities: [this.buildEntityIdentifier(entityKey)], + }, }, - ], - }); + action: { name: 'read' }, + resources: [ + { + ephemeralId: room._id, + attributeValues: { fqns: this.buildAttributeFqns(attributes) }, + }, + ], + }, + ]); + + const decision = responses[0]?.resourceDecisions?.[0]?.decision; if (decision === 'DECISION_PERMIT') { pdpLogger.debug({ msg: 'Virtru PDP canAccessObject: permitted', roomId: room._id, userId: user._id }); diff --git a/ee/packages/abac/src/pdp/types.ts b/ee/packages/abac/src/pdp/types.ts index 22a8d4b855c4e..049d91b9bdcaf 100644 --- a/ee/packages/abac/src/pdp/types.ts +++ b/ee/packages/abac/src/pdp/types.ts @@ -2,18 +2,6 @@ import type { IAbacAttributeDefinition, IRoom, IUser, AtLeast } from '@rocket.ch export type IEntityIdentifier = { emailAddress: string } | { id: string }; -export interface IGetDecisionRequest { - actions: Array<{ standard: number }>; - resourceAttributes: Array<{ - resourceAttributesId: string; - attributeValueFqns: string[]; - }>; - entityChains: Array<{ - id: string; - entities: IEntityIdentifier[]; - }>; -} - export interface IGetDecisionBulkRequest { entityIdentifier: { entityChain: { @@ -34,12 +22,6 @@ export interface IResourceDecision { ephemeralResourceId?: string; } -export interface IGetDecisionsResponse { - decisionResponses?: Array<{ - decision?: Decision; - }>; -} - export interface IGetDecisionBulkResponse { decisionResponses?: Array<{ resourceDecisions?: IResourceDecision[]; From 6a2222780720a3d67355d16288335a67d38bfd0b Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 26 May 2026 16:41:35 -0600 Subject: [PATCH 018/161] test: Missing reporter for livechat API tests (#40703) --- apps/meteor/.mocharc.api.livechat.js | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/meteor/.mocharc.api.livechat.js b/apps/meteor/.mocharc.api.livechat.js index 48a3a13e2751f..7b98b659bc8de 100644 --- a/apps/meteor/.mocharc.api.livechat.js +++ b/apps/meteor/.mocharc.api.livechat.js @@ -10,5 +10,6 @@ module.exports = /** @satisfies {import('mocha').MochaOptions} */ ({ bail: true, retries: 0, file: 'tests/end-to-end/teardown.ts', + reporter: 'tests/end-to-end/reporter.ts', spec: ['tests/end-to-end/api/livechat/**/*'], }); From 9dcadc107312c03c42104b94e7409d8c4da52850 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Wed, 27 May 2026 04:19:15 -0300 Subject: [PATCH 019/161] fix: add missing authorization check to fingerprint endpoint (#40706) --- .changeset/salty-suits-strive.md | 5 ++ apps/meteor/app/api/server/v1/misc.ts | 3 ++ .../tests/end-to-end/api/miscellaneous.ts | 47 +++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 .changeset/salty-suits-strive.md diff --git a/.changeset/salty-suits-strive.md b/.changeset/salty-suits-strive.md new file mode 100644 index 0000000000000..e9506dc744605 --- /dev/null +++ b/.changeset/salty-suits-strive.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes missing permission check on the `POST /api/v1/fingerprint` endpoint diff --git a/apps/meteor/app/api/server/v1/misc.ts b/apps/meteor/app/api/server/v1/misc.ts index cf7a9a2d3d1bf..d7b0be701a3f5 100644 --- a/apps/meteor/app/api/server/v1/misc.ts +++ b/apps/meteor/app/api/server/v1/misc.ts @@ -11,6 +11,7 @@ import { isMeteorCall, meSuccessResponseSchema, validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, validateBadRequestErrorResponse, } from '@rocket.chat/rest-typings'; import type { MeApiSuccessResponse } from '@rocket.chat/rest-typings'; @@ -795,10 +796,12 @@ API.v1.post( 'fingerprint', { authRequired: true, + permissionsRequired: ['manage-cloud'], body: isFingerprintProps, response: { 200: fingerprintResponseSchema, 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, 400: validateBadRequestErrorResponse, }, }, diff --git a/apps/meteor/tests/end-to-end/api/miscellaneous.ts b/apps/meteor/tests/end-to-end/api/miscellaneous.ts index 0f3f7de85abfd..1196c95e24a49 100644 --- a/apps/meteor/tests/end-to-end/api/miscellaneous.ts +++ b/apps/meteor/tests/end-to-end/api/miscellaneous.ts @@ -686,4 +686,51 @@ describe('miscellaneous', () => { .end(done); }); }); + + describe('/fingerprint', () => { + let unauthorizedUser: TestUser; + let unauthorizedUserCredentials: Credentials; + + before(async () => { + unauthorizedUser = await createUser(); + unauthorizedUserCredentials = await doLogin(unauthorizedUser.username, password); + }); + + after(async () => { + await deleteUser(unauthorizedUser); + }); + + it('should return 401 when called without authentication', async () => { + const res = await request.post(api('fingerprint')).send({ setDeploymentAs: 'updated-configuration' }); + + expect(res.status).to.equal(401); + expect(res.body).to.have.property('status', 'error'); + }); + + it('should return 403 when a user without the manage-cloud permission tries to acknowledge a deployment configuration change', async () => { + const res = await request + .post(api('fingerprint')) + .set(unauthorizedUserCredentials) + .send({ setDeploymentAs: 'updated-configuration' }); + + expect(res.status).to.equal(403); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + + it('should return 403 when a user without the manage-cloud permission tries to deregister the workspace as a new workspace', async () => { + const res = await request.post(api('fingerprint')).set(unauthorizedUserCredentials).send({ setDeploymentAs: 'new-workspace' }); + + expect(res.status).to.equal(403); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + + it('should return 200 when a user with the manage-cloud permission acknowledges a deployment configuration change', async () => { + const res = await request.post(api('fingerprint')).set(credentials).send({ setDeploymentAs: 'updated-configuration' }); + + expect(res.status).to.equal(200); + expect(res.body).to.have.property('success', true); + }); + }); }); From 82aad55cd74b05d83febf87eb403e330f0f4ae5c Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 27 May 2026 11:55:45 -0300 Subject: [PATCH 020/161] chore: migrate low-friction DDP callers to REST endpoints (#40704) --- apps/meteor/app/api/server/v1/users.ts | 20 ++++++++++++------- .../app/e2e/server/methods/resetOwnE2EKey.ts | 2 ++ .../app/lib/server/methods/addUsersToRoom.ts | 2 ++ .../app/lib/server/methods/leaveRoom.ts | 2 ++ apps/meteor/app/utils/server/slashCommand.ts | 3 +++ .../server/methods/banner_dismiss.ts | 2 ++ .../lib/chats/flows/processSetReaction.ts | 10 ++++++++-- .../lib/chats/flows/processSlashCommand.ts | 8 +++++++- apps/meteor/client/lib/fetchFeatures.ts | 5 ++++- .../preferences/PreferencesMyDataSection.tsx | 6 +++--- .../hooks/useDismissUserBannerMutation.ts | 6 +++--- .../hooks/useResetE2EPasswordMutation.ts | 6 +++--- .../Info/hooks/actions/useRoomLeave.tsx | 14 ++++++++++--- .../RoomMembers/AddUsers/AddUsers.tsx | 15 ++++++++++---- .../root/hooks/loggedIn/useRestrictedRoles.ts | 6 +++--- apps/meteor/ee/app/license/server/methods.ts | 2 ++ .../server/methods/requestDataDownload.ts | 2 ++ apps/meteor/tests/data/rooms.helper.ts | 9 +++++++++ .../e2e/e2e-encryption/resetOwnE2EKey.ts | 8 ++------ .../federation-matrix/docker-compose.test.yml | 2 +- packages/rest-typings/src/v1/commands.ts | 2 +- .../rest-typings/src/v1/groups/BaseProps.ts | 19 +++++++++++++----- packages/rest-typings/src/v1/users.ts | 3 +++ 23 files changed, 111 insertions(+), 43 deletions(-) diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index 46ec48b96833b..dc7ed2344be9d 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -670,7 +670,7 @@ API.v1.addRoute( if (!canViewFullOtherUserInfo) { return API.v1.forbidden(); } - const escapedEmail = escapeRegExp(this.queryParams.email as string); + const escapedEmail = escapeRegExp(this.queryParams.email); nonEmptyQuery['emails.address'] = { $regex: `^${escapedEmail}$`, $options: 'i', @@ -1580,15 +1580,22 @@ API.v1 authRequired: true, query: isUsersRequestDataDownloadParamsGET, response: { - 200: ajv.compile<{ requested: boolean; exportOperation: IExportOperation }>({ + 200: ajv.compile<{ + requested: boolean; + exportOperation: IExportOperation; + url: string | null; + pendingOperationsBeforeMyRequest: number; + }>({ type: 'object', properties: { requested: { type: 'boolean' }, // IExportOperation has complex/dynamic shape not yet in typia exportOperation: { type: 'object' }, + url: { type: 'string', nullable: true }, + pendingOperationsBeforeMyRequest: { type: 'number' }, success: { type: 'boolean', enum: [true] }, }, - required: ['requested', 'exportOperation', 'success'], + required: ['requested', 'exportOperation', 'pendingOperationsBeforeMyRequest', 'success'], additionalProperties: false, }), 401: validateUnauthorizedErrorResponse, @@ -1596,14 +1603,13 @@ API.v1 }, async function action() { const { fullExport = false } = this.queryParams; - const result = (await requestDataDownload({ userData: this.user, fullExport: fullExport === 'true' })) as { - requested: boolean; - exportOperation: IExportOperation; - }; + const result = await requestDataDownload({ userData: this.user, fullExport: fullExport === 'true' }); return API.v1.success({ requested: Boolean(result.requested), exportOperation: result.exportOperation, + url: result.url, + pendingOperationsBeforeMyRequest: result.pendingOperationsBeforeMyRequest, }); }, ) diff --git a/apps/meteor/app/e2e/server/methods/resetOwnE2EKey.ts b/apps/meteor/app/e2e/server/methods/resetOwnE2EKey.ts index 13e59e0286554..2c63a89b6a382 100644 --- a/apps/meteor/app/e2e/server/methods/resetOwnE2EKey.ts +++ b/apps/meteor/app/e2e/server/methods/resetOwnE2EKey.ts @@ -4,6 +4,7 @@ import { Meteor } from 'meteor/meteor'; import { resetUserE2EEncriptionKey } from '../../../../server/lib/resetUserE2EKey'; import { twoFactorRequired } from '../../../2fa/server/twoFactorRequired'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -14,6 +15,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ 'e2e.resetOwnE2EKey': twoFactorRequired(async () => { + methodDeprecationLogger.method('e2e.resetOwnE2EKey', '9.0.0', '/v1/users.resetE2EKey'); const userId = Meteor.userId(); if (!userId) { diff --git a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts index c222a7a8b753d..81aaa465e7f4e 100644 --- a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts +++ b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts @@ -9,6 +9,7 @@ import { beforeAddUsersToRoom } from '../../../../server/lib/callbacks/beforeAdd import { i18n } from '../../../../server/lib/i18n'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { addUserToRoom } from '../functions/addUserToRoom'; +import { methodDeprecationLogger } from '../lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -122,6 +123,7 @@ export const addUsersToRoomMethod = async (userId: string, data: { rid: string; Meteor.methods({ async addUsersToRoom(data) { + methodDeprecationLogger.method('addUsersToRoom', '9.0.0', ['/v1/channels.invite', '/v1/groups.invite']); const uid = Meteor.userId(); // Validate user and room if (!uid) { diff --git a/apps/meteor/app/lib/server/methods/leaveRoom.ts b/apps/meteor/app/lib/server/methods/leaveRoom.ts index 8ded8b56ce2b0..171e37ce54ec7 100644 --- a/apps/meteor/app/lib/server/methods/leaveRoom.ts +++ b/apps/meteor/app/lib/server/methods/leaveRoom.ts @@ -9,6 +9,7 @@ import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { hasRoleAsync } from '../../../authorization/server/functions/hasRole'; import { removeUserFromRoom } from '../functions/removeUserFromRoom'; +import { methodDeprecationLogger } from '../lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -58,6 +59,7 @@ export const leaveRoomMethod = async (user: IUser, rid: string): Promise = Meteor.methods({ async leaveRoom(rid) { + methodDeprecationLogger.method('leaveRoom', '9.0.0', ['/v1/channels.leave', '/v1/groups.leave', '/v1/im.leave']); check(rid, String); if (!Meteor.userId()) { diff --git a/apps/meteor/app/utils/server/slashCommand.ts b/apps/meteor/app/utils/server/slashCommand.ts index 42eb86f5f9b19..faa9f0606650f 100644 --- a/apps/meteor/app/utils/server/slashCommand.ts +++ b/apps/meteor/app/utils/server/slashCommand.ts @@ -10,6 +10,8 @@ import type { import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../../lib/server/lib/deprecationWarningLogger'; + interface ISlashCommandAddParams { command: string; callback?: SlashCommand['callback']; @@ -139,6 +141,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async slashCommand(command) { + methodDeprecationLogger.method('slashCommand', '9.0.0', '/v1/commands.run'); const userId = Meteor.userId(); if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { diff --git a/apps/meteor/app/version-check/server/methods/banner_dismiss.ts b/apps/meteor/app/version-check/server/methods/banner_dismiss.ts index a3bf337626f92..5b89641262d05 100644 --- a/apps/meteor/app/version-check/server/methods/banner_dismiss.ts +++ b/apps/meteor/app/version-check/server/methods/banner_dismiss.ts @@ -2,6 +2,7 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; import { notifyOnUserChange } from '../../../lib/server/lib/notifyListener'; declare module '@rocket.chat/ddp-client' { @@ -13,6 +14,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async 'banner/dismiss'({ id }) { + methodDeprecationLogger.method('banner/dismiss', '9.0.0', '/v1/banners.dismiss'); const userId = Meteor.userId(); if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'banner/dismiss' }); diff --git a/apps/meteor/client/lib/chats/flows/processSetReaction.ts b/apps/meteor/client/lib/chats/flows/processSetReaction.ts index 74e28efa937d7..9d5507c761df2 100644 --- a/apps/meteor/client/lib/chats/flows/processSetReaction.ts +++ b/apps/meteor/client/lib/chats/flows/processSetReaction.ts @@ -2,7 +2,8 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { emoji } from '../../../../app/emoji/client'; import { runOptimisticSetReaction } from '../../../../app/reactions/client/methods/setReaction'; -import { callWithErrorHandling } from '../../utils/callWithErrorHandling'; +import { sdk } from '../../../../app/utils/client/lib/SDKClient'; +import { dispatchToastMessage } from '../../toast'; import type { ChatAPI } from '../ChatAPI'; export const processSetReaction = async (chat: ChatAPI, { msg }: Pick): Promise => { @@ -24,6 +25,11 @@ export const processSetReaction = async (chat: ChatAPI, { msg }: Pick => whenLoggedIn().then(() => sdk.call('license:getModules')); +export const fetchFeatures = (): Promise => + whenLoggedIn() + .then(() => sdk.rest.get('/v1/licenses.info', {})) + .then(({ license }) => license.activeModules); diff --git a/apps/meteor/client/views/account/preferences/PreferencesMyDataSection.tsx b/apps/meteor/client/views/account/preferences/PreferencesMyDataSection.tsx index c580f8389588f..250519bcf39e0 100644 --- a/apps/meteor/client/views/account/preferences/PreferencesMyDataSection.tsx +++ b/apps/meteor/client/views/account/preferences/PreferencesMyDataSection.tsx @@ -1,5 +1,5 @@ import { AccordionItem, Box, Button, ButtonGroup } from '@rocket.chat/fuselage'; -import { useMethod, useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import DOMPurify from 'dompurify'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -11,12 +11,12 @@ const PreferencesMyDataSection = () => { const setModal = useSetModal(); const dispatchToastMessage = useToastMessageDispatch(); - const requestDataDownload = useMethod('requestDataDownload'); + const requestDataDownload = useEndpoint('GET', '/v1/users.requestDataDownload'); const downloadData = useCallback( async (fullExport: boolean) => { try { - const result = await requestDataDownload({ fullExport }); + const result = await requestDataDownload({ fullExport: fullExport ? 'true' : 'false' }); if (result.requested) { const text = t('UserDataDownload_Requested_Text', { pending_operations: result.pendingOperationsBeforeMyRequest, diff --git a/apps/meteor/client/views/banners/hooks/useDismissUserBannerMutation.ts b/apps/meteor/client/views/banners/hooks/useDismissUserBannerMutation.ts index 5a881d4b5ec56..8b424b0450790 100644 --- a/apps/meteor/client/views/banners/hooks/useDismissUserBannerMutation.ts +++ b/apps/meteor/client/views/banners/hooks/useDismissUserBannerMutation.ts @@ -1,13 +1,13 @@ -import { useMethod, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import { useMutation } from '@tanstack/react-query'; export const useDismissUserBannerMutation = () => { - const dismissBanner = useMethod('banner/dismiss'); + const dismissBanner = useEndpoint('POST', '/v1/banners.dismiss'); const dispatchToastMessage = useToastMessageDispatch(); return useMutation({ - mutationFn: dismissBanner, + mutationFn: ({ id }: { id: string }) => dismissBanner({ bannerId: id }), onError: (error) => { dispatchToastMessage({ type: 'error', message: error }); }, diff --git a/apps/meteor/client/views/hooks/useResetE2EPasswordMutation.ts b/apps/meteor/client/views/hooks/useResetE2EPasswordMutation.ts index 03c2c4ba32911..641963057ce30 100644 --- a/apps/meteor/client/views/hooks/useResetE2EPasswordMutation.ts +++ b/apps/meteor/client/views/hooks/useResetE2EPasswordMutation.ts @@ -1,4 +1,4 @@ -import { useLogout, useMethod, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useLogout, useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import type { MutationOptions } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -7,11 +7,11 @@ export const useResetE2EPasswordMutation = ({ options }: { options?: MutationOpt const { t } = useTranslation(); const logout = useLogout(); - const resetE2eKey = useMethod('e2e.resetOwnE2EKey'); + const resetE2eKey = useEndpoint('POST', '/v1/users.resetE2EKey'); const dispatchToastMessage = useToastMessageDispatch(); return useMutation({ - mutationFn: async () => resetE2eKey(), + mutationFn: async () => resetE2eKey({}), onSuccess: () => { dispatchToastMessage({ type: 'success', message: t('E2EE_password_reset') }); logout(); diff --git a/apps/meteor/client/views/room/contextualBar/Info/hooks/actions/useRoomLeave.tsx b/apps/meteor/client/views/room/contextualBar/Info/hooks/actions/useRoomLeave.tsx index 485ebcf868d25..4b87fd73229e2 100644 --- a/apps/meteor/client/views/room/contextualBar/Info/hooks/actions/useRoomLeave.tsx +++ b/apps/meteor/client/views/room/contextualBar/Info/hooks/actions/useRoomLeave.tsx @@ -1,7 +1,7 @@ import type { IRoom } from '@rocket.chat/core-typings'; import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; -import { useRouter, useSetModal, useToastMessageDispatch, useMethod, usePermission, useUserSubscription } from '@rocket.chat/ui-contexts'; +import { useRouter, useSetModal, useToastMessageDispatch, useEndpoint, usePermission, useUserSubscription } from '@rocket.chat/ui-contexts'; import { useTranslation } from 'react-i18next'; import { LegacyRoomManager } from '../../../../../../../app/ui-utils/client'; @@ -14,7 +14,9 @@ export const useRoomLeave = (room: IRoom) => { const subscription = useUserSubscription(room._id); const setModal = useSetModal(); const dispatchToastMessage = useToastMessageDispatch(); - const leaveRoom = useMethod('leaveRoom'); + const leaveChannel = useEndpoint('POST', '/v1/channels.leave'); + const leaveGroup = useEndpoint('POST', '/v1/groups.leave'); + const leaveDirect = useEndpoint('POST', '/v1/im.leave'); const router = useRouter(); const canLeave = usePermission(room.t === 'c' ? 'leave-c' : 'leave-p') && room.cl !== false && Boolean(subscription); @@ -22,7 +24,13 @@ export const useRoomLeave = (room: IRoom) => { const handleLeave = useEffectEvent(() => { const leaveAction = async () => { try { - await leaveRoom(room._id); + if (room.t === 'c') { + await leaveChannel({ roomId: room._id }); + } else if (room.t === 'p') { + await leaveGroup({ roomId: room._id }); + } else if (room.t === 'd') { + await leaveDirect({ roomId: room._id }); + } router.navigate('/home'); if (room.name) { diff --git a/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.tsx b/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.tsx index 42a068bedfabc..fc000223a939d 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.tsx +++ b/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.tsx @@ -11,7 +11,7 @@ import { ContextualbarFooter, ContextualbarDialog, } from '@rocket.chat/ui-client'; -import { useToastMessageDispatch, useMethod, useSetModal, useEndpoint, useRoomToolbox } from '@rocket.chat/ui-contexts'; +import { useToastMessageDispatch, useSetModal, useEndpoint, useRoomToolbox } from '@rocket.chat/ui-contexts'; import { useId } from 'react'; import type { ReactElement } from 'react'; import { Controller, useForm } from 'react-hook-form'; @@ -42,7 +42,8 @@ const AddUsers = ({ rid, onClickBack, reload }: AddUsersProps): ReactElement => const setModal = useSetModal(); const { closeTab } = useRoomToolbox(); - const saveAction = useMethod('addUsersToRoom'); + const inviteToChannel = useEndpoint('POST', '/v1/channels.invite'); + const inviteToGroup = useEndpoint('POST', '/v1/groups.invite'); const getBannedUsers = useEndpoint('GET', '/v1/rooms.bannedUsers'); const unbanUser = useEndpoint('POST', '/v1/rooms.unbanUser'); @@ -63,7 +64,9 @@ const AddUsers = ({ rid, onClickBack, reload }: AddUsersProps): ReactElement => await Promise.all(usersToUnban.map((username) => unbanUser({ roomId: rid, username }))); } } - await saveAction({ rid, users }); + await Promise.all( + users.map((username) => (room.t === 'c' ? inviteToChannel({ roomId: rid, username }) : inviteToGroup({ roomId: rid, username }))), + ); dispatchToastMessage({ type: 'success', message: t(roomIsFederated && !isFederationBlocked ? 'Users_invited' : 'Users_added') }); onClickBack(); reload(); @@ -84,7 +87,11 @@ const AddUsers = ({ rid, onClickBack, reload }: AddUsersProps): ReactElement => onClose={() => setModal(null)} onConfirm={async () => { await Promise.all(usersToUnban.map((username) => unbanUser({ roomId: rid, username }))); - await saveAction({ rid, users }); + await Promise.all( + users.map((username) => + room.t === 'c' ? inviteToChannel({ roomId: rid, username }) : inviteToGroup({ roomId: rid, username }), + ), + ); setModal(null); dispatchToastMessage({ type: 'success', diff --git a/apps/meteor/client/views/root/hooks/loggedIn/useRestrictedRoles.ts b/apps/meteor/client/views/root/hooks/loggedIn/useRestrictedRoles.ts index 57bbb0baf1afa..f4bc67c4e743b 100644 --- a/apps/meteor/client/views/root/hooks/loggedIn/useRestrictedRoles.ts +++ b/apps/meteor/client/views/root/hooks/loggedIn/useRestrictedRoles.ts @@ -1,15 +1,15 @@ -import { useMethod } from '@rocket.chat/ui-contexts'; +import { useEndpoint } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; import { useEffect } from 'react'; import { AuthorizationUtils } from '../../../../../app/authorization/lib'; export const useRestrictedRoles = (): void => { - const isEnterpriseQuery = useMethod('license:isEnterprise'); + const getLicenseInfo = useEndpoint('GET', '/v1/licenses.info'); const { data: isEnterprise, isSuccess } = useQuery({ queryKey: ['isEnterprise'], - queryFn: isEnterpriseQuery, + queryFn: async () => (await getLicenseInfo({})).license.hasValidLicense, }); useEffect(() => { diff --git a/apps/meteor/ee/app/license/server/methods.ts b/apps/meteor/ee/app/license/server/methods.ts index 31a9638aa6158..51eed30e6f619 100644 --- a/apps/meteor/ee/app/license/server/methods.ts +++ b/apps/meteor/ee/app/license/server/methods.ts @@ -24,6 +24,7 @@ Meteor.methods({ return License.hasModule(feature as LicenseModule); }, 'license:getModules'() { + methodDeprecationLogger.method('license:getModules', '9.0.0', '/v1/licenses.info'); return License.getModules(); }, 'license:getTags'() { @@ -31,6 +32,7 @@ Meteor.methods({ return License.getTags(); }, 'license:isEnterprise'() { + methodDeprecationLogger.method('license:isEnterprise', '9.0.0', '/v1/licenses.info'); return License.hasValidLicense(); }, }); diff --git a/apps/meteor/server/methods/requestDataDownload.ts b/apps/meteor/server/methods/requestDataDownload.ts index 7074c8429947a..da1394a31939f 100644 --- a/apps/meteor/server/methods/requestDataDownload.ts +++ b/apps/meteor/server/methods/requestDataDownload.ts @@ -7,6 +7,7 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; import { ExportOperations, UserDataFiles } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; import { settings } from '../../app/settings/server'; import * as dataExport from '../lib/dataExport'; @@ -110,6 +111,7 @@ export const requestDataDownload = async ({ Meteor.methods({ async requestDataDownload({ fullExport = false }) { + methodDeprecationLogger.method('requestDataDownload', '9.0.0', '/v1/users.requestDataDownload'); const currentUserData = await Meteor.userAsync(); if (!currentUserData) { diff --git a/apps/meteor/tests/data/rooms.helper.ts b/apps/meteor/tests/data/rooms.helper.ts index e83a904c3f994..90d8766d6a455 100644 --- a/apps/meteor/tests/data/rooms.helper.ts +++ b/apps/meteor/tests/data/rooms.helper.ts @@ -129,6 +129,11 @@ export const getSubscriptionByRoomId = (roomId: IRoom['_id'], userCredentials = * @param config - Optional request configuration for custom domains * @returns Promise resolving to the method call response */ +// TODO(ddp-removal): swap /api/v1/method.call/addUsersToRoom for the per-type +// REST endpoints (/v1/channels.invite or /v1/groups.invite). The federation +// suite still asserts the legacy DDP error shape (`message` field with a +// stringified `{ error: { error } }`); migrating requires updating those +// expectations to the REST envelope (`{ success: false, error }`). export const addUserToRoom = ({ usernames, rid, @@ -169,6 +174,10 @@ export const addUserToRoom = ({ * @returns Promise resolving to the method call response * @note The slash command expects parameters: { cmd: string, params: string, msg: IMessage, triggerId: string } */ +// TODO(ddp-removal): swap /api/v1/method.call/slashCommand for +// POST /v1/commands.run. Same caveat as addUserToRoom: federation specs +// inspect the DDP-style `message` payload and need to be ported to the +// REST envelope first. export const addUserToRoomSlashCommand = ({ usernames, rid, diff --git a/apps/meteor/tests/e2e/e2e-encryption/resetOwnE2EKey.ts b/apps/meteor/tests/e2e/e2e-encryption/resetOwnE2EKey.ts index 10c22ecd687b9..39edb8c8f6bea 100644 --- a/apps/meteor/tests/e2e/e2e-encryption/resetOwnE2EKey.ts +++ b/apps/meteor/tests/e2e/e2e-encryption/resetOwnE2EKey.ts @@ -8,10 +8,6 @@ type Credentials = { email?: string; }; -const resetOwnE2EKeyMethod = { - message: JSON.stringify({ msg: 'method', id: '1', method: 'e2e.resetOwnE2EKey', params: [] }), -}; - export const resetOwnE2EKey = async (credentials: Credentials) => { const request = await baseRequest.newContext(); @@ -19,8 +15,8 @@ export const resetOwnE2EKey = async (credentials: Credentials) => { const loginResponse = await request.post(`${BASE_API_URL}/login`, { data: credentials }); const { data } = await loginResponse.json(); - return await request.post(`${BASE_API_URL}/method.call/e2e.resetOwnE2EKey`, { - data: resetOwnE2EKeyMethod, + return await request.post(`${BASE_API_URL}/users.resetE2EKey`, { + data: { userId: data.userId }, headers: { 'X-Auth-Token': data.authToken, 'X-User-Id': data.userId, diff --git a/ee/packages/federation-matrix/docker-compose.test.yml b/ee/packages/federation-matrix/docker-compose.test.yml index 40dbd4e224907..ad06faeb0ef38 100644 --- a/ee/packages/federation-matrix/docker-compose.test.yml +++ b/ee/packages/federation-matrix/docker-compose.test.yml @@ -101,7 +101,7 @@ services: ADMIN_USERNAME: admin ADMIN_PASS: admin ADMIN_EMAIL: admin@admin.com - TEST_MODE: true + TEST_MODE: api volumes: - ./docker-compose/traefik/certs/ca/rootCA.crt:/usr/local/share/ca-certificates/rootCA.pem networks: diff --git a/packages/rest-typings/src/v1/commands.ts b/packages/rest-typings/src/v1/commands.ts index 9d46105cac7f7..388b4c1d1c089 100644 --- a/packages/rest-typings/src/v1/commands.ts +++ b/packages/rest-typings/src/v1/commands.ts @@ -2,7 +2,7 @@ import type { SlashCommandPreviews } from '@rocket.chat/core-typings'; export type CommandsEndpoints = { '/v1/commands.run': { - POST: (params: { command: string; params?: string; roomId: string; tmid?: string; triggerId: string }) => { + POST: (params: { command: string; params?: string; roomId: string; tmid?: string; triggerId?: string }) => { result: unknown; }; }; diff --git a/packages/rest-typings/src/v1/groups/BaseProps.ts b/packages/rest-typings/src/v1/groups/BaseProps.ts index 4f6fb6c0c50bc..01d7fc6e612ce 100644 --- a/packages/rest-typings/src/v1/groups/BaseProps.ts +++ b/packages/rest-typings/src/v1/groups/BaseProps.ts @@ -33,13 +33,22 @@ export type BaseProps = GroupsBaseProps; export const baseSchema = withGroupBaseProperties(); export const withBaseProps = ajv.compile(baseSchema); -export type WithUserId = GroupsBaseProps & { userId: string }; +export type WithUserId = GroupsBaseProps & + ( + | { userId: string; username?: string; user?: string; userIds?: string[]; usernames?: string[] } + | { userId?: string; username: string; user?: string; userIds?: string[]; usernames?: string[] } + | { userId?: string; username?: string; user: string; userIds?: string[]; usernames?: string[] } + | { userId?: string; username?: string; user?: string; userIds: string[]; usernames?: string[] } + | { userId?: string; username?: string; user?: string; userIds?: string[]; usernames: string[] } + ); export const withUserIdSchema = withGroupBaseProperties( { - userId: { - type: 'string', - }, + userId: { type: 'string', nullable: true }, + username: { type: 'string', nullable: true }, + user: { type: 'string', nullable: true }, + userIds: { type: 'array', items: { type: 'string' }, nullable: true }, + usernames: { type: 'array', items: { type: 'string' }, nullable: true }, }, - ['userId'], + [], ); export const withUserIdProps = ajv.compile(withUserIdSchema); diff --git a/packages/rest-typings/src/v1/users.ts b/packages/rest-typings/src/v1/users.ts index 88333135ccb06..dda7d95c75330 100644 --- a/packages/rest-typings/src/v1/users.ts +++ b/packages/rest-typings/src/v1/users.ts @@ -179,6 +179,8 @@ export type UsersEndpoints = { GET: (params: { fullExport?: 'true' | 'false' }) => { requested: boolean; exportOperation: IExportOperation; + url: string | null; + pendingOperationsBeforeMyRequest: number; }; }; @@ -196,6 +198,7 @@ export type UsersEndpoints = { '/v1/users.resetE2EKey': { POST: ( params: + | Record | { userId: string; } From 741b87fb95e4f8159c62e5599a403bda1c9bbb8f Mon Sep 17 00:00:00 2001 From: Yash Rajpal <58601732+yash-rajpal@users.noreply.github.com> Date: Thu, 28 May 2026 01:00:24 +0530 Subject: [PATCH 021/161] revert: Phishing resistant multi factor authentication (#40679) Co-authored-by: Tasso Evangelista --- .changeset/flat-poets-cheat.md | 28 -- apps/meteor/app/2fa/server/code/EmailCheck.ts | 4 +- .../app/2fa/server/code/EmailCheckForOAuth.ts | 37 -- apps/meteor/app/2fa/server/code/TOTPCheck.ts | 2 +- .../app/2fa/server/code/TOTPCheckForOAuth.ts | 35 -- apps/meteor/app/2fa/server/code/index.ts | 21 +- apps/meteor/app/api/server/ApiClass.ts | 2 +- apps/meteor/app/api/server/index.ts | 2 +- .../app/api/server/v1/twoFactorChallenges.ts | 112 ----- .../app/apple/lib/handleIdentityToken.ts | 7 +- .../apple/server/appleOauthRegisterService.ts | 133 ++--- apps/meteor/app/apple/server/loginHandler.ts | 4 +- .../app/custom-oauth/server/customOAuth.ts | 393 --------------- .../server/custom_oauth_server.js | 3 - apps/meteor/app/dolphin/server/lib.ts | 39 +- apps/meteor/app/drupal/server/lib.ts | 40 +- apps/meteor/app/gitlab/server/lib.ts | 49 +- .../app/lib/server/methods/createToken.ts | 2 +- apps/meteor/app/linkedin/server/index.ts | 1 - apps/meteor/app/linkedin/server/lib.ts | 49 -- .../app/meteor-developer/server/index.ts | 1 - .../meteor/app/meteor-developer/server/lib.ts | 46 -- apps/meteor/app/nextcloud/server/lib.ts | 45 +- apps/meteor/app/wordpress/server/lib.ts | 26 +- .../TwoFactorModal/TwoFactorEmailModal.tsx | 13 +- .../TwoFactorModal/TwoFactorModal.tsx | 6 +- .../meteor/client/lib/2fa/process2faReturn.ts | 9 +- .../meteor/client/lib/buildAuthDeeplinkURL.ts | 5 - apps/meteor/client/lib/sdk/ddpSdk.ts | 2 +- apps/meteor/client/startup/routes.tsx | 10 - .../OAuthTwoFactorAuthenticationRouter.tsx | 101 ---- apps/meteor/client/views/root/AppLayout.tsx | 4 - .../views/root/hooks/useLoginOtherClients.ts | 30 -- .../views/root/hooks/useLoginViaQuery.ts | 7 +- .../hooks/useShareSessionWithOtherClients.ts | 41 -- .../definition/externals/express-session.d.ts | 7 - apps/meteor/definition/externals/express.d.ts | 6 - .../externals/meteor/accounts-base.d.ts | 6 +- apps/meteor/package.json | 17 - .../server/configuration/accounts_meld.js | 4 + .../server/configuration/configurePassport.ts | 93 ---- apps/meteor/server/configuration/index.ts | 2 - apps/meteor/server/importPackages.ts | 2 - .../lib/oauth/addPassportCustomOAuth.ts | 53 -- .../lib/oauth/configureOAuthServices.ts | 87 ---- .../lib/oauth/createOAuthServiceConfig.ts | 59 --- .../server/lib/oauth/getOAuthServices.ts | 34 -- apps/meteor/server/lib/oauth/oauthConfigs.ts | 36 -- .../server/lib/oauth/passportOAuthCallback.ts | 51 -- apps/meteor/server/lib/oauth/twoFactorAuth.ts | 37 -- .../server/lib/oauth/updateOAuthServices.ts | 10 +- .../meteor/server/lib/oauth/verifyFunction.ts | 40 -- apps/meteor/server/models.ts | 2 - apps/meteor/server/settings/oauth.ts | 7 - .../tests/e2e/fixtures/addCustomOAuth.ts | 3 - .../src/ILoginServiceConfiguration.ts | 5 - .../core-typings/src/ITwoFactorChallenge.ts | 7 - packages/core-typings/src/IUser.ts | 2 +- packages/core-typings/src/index.ts | 2 - packages/desktop-api/src/index.ts | 1 - packages/i18n/src/locales/en.i18n.json | 3 - packages/model-typings/src/index.ts | 1 - .../src/models/ITwoFactorChallengesModel.ts | 10 - packages/models/src/index.ts | 2 - packages/models/src/modelClasses.ts | 1 - .../models/src/models/TwoFactorChallenges.ts | 38 -- packages/rest-typings/src/index.ts | 3 - .../src/v1/twoFactorChallenges.ts | 43 -- packages/web-ui-registration/global.d.ts | 7 - .../web-ui-registration/src/LoginServices.tsx | 43 +- .../src/LoginServicesButton.tsx | 19 +- .../web-ui-registration/tsconfig.build.json | 2 +- packages/web-ui-registration/tsconfig.json | 2 +- yarn.lock | 467 +++++------------- 74 files changed, 275 insertions(+), 2248 deletions(-) delete mode 100644 .changeset/flat-poets-cheat.md delete mode 100644 apps/meteor/app/2fa/server/code/EmailCheckForOAuth.ts delete mode 100644 apps/meteor/app/2fa/server/code/TOTPCheckForOAuth.ts delete mode 100644 apps/meteor/app/api/server/v1/twoFactorChallenges.ts delete mode 100644 apps/meteor/app/custom-oauth/server/customOAuth.ts delete mode 100644 apps/meteor/app/linkedin/server/index.ts delete mode 100644 apps/meteor/app/linkedin/server/lib.ts delete mode 100644 apps/meteor/app/meteor-developer/server/index.ts delete mode 100644 apps/meteor/app/meteor-developer/server/lib.ts delete mode 100644 apps/meteor/client/lib/buildAuthDeeplinkURL.ts delete mode 100644 apps/meteor/client/views/OAuthTwoFactorAuthentication/OAuthTwoFactorAuthenticationRouter.tsx delete mode 100644 apps/meteor/client/views/root/hooks/useLoginOtherClients.ts delete mode 100644 apps/meteor/client/views/root/hooks/useShareSessionWithOtherClients.ts delete mode 100644 apps/meteor/definition/externals/express-session.d.ts delete mode 100644 apps/meteor/server/configuration/configurePassport.ts delete mode 100644 apps/meteor/server/lib/oauth/addPassportCustomOAuth.ts delete mode 100644 apps/meteor/server/lib/oauth/configureOAuthServices.ts delete mode 100644 apps/meteor/server/lib/oauth/createOAuthServiceConfig.ts delete mode 100644 apps/meteor/server/lib/oauth/getOAuthServices.ts delete mode 100644 apps/meteor/server/lib/oauth/oauthConfigs.ts delete mode 100644 apps/meteor/server/lib/oauth/passportOAuthCallback.ts delete mode 100644 apps/meteor/server/lib/oauth/twoFactorAuth.ts delete mode 100644 apps/meteor/server/lib/oauth/verifyFunction.ts delete mode 100644 packages/core-typings/src/ITwoFactorChallenge.ts delete mode 100644 packages/model-typings/src/models/ITwoFactorChallengesModel.ts delete mode 100644 packages/models/src/models/TwoFactorChallenges.ts delete mode 100644 packages/rest-typings/src/v1/twoFactorChallenges.ts delete mode 100644 packages/web-ui-registration/global.d.ts diff --git a/.changeset/flat-poets-cheat.md b/.changeset/flat-poets-cheat.md deleted file mode 100644 index 2560e2d3ee172..0000000000000 --- a/.changeset/flat-poets-cheat.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@rocket.chat/web-ui-registration': minor -'@rocket.chat/model-typings': minor -'@rocket.chat/core-typings': minor -'@rocket.chat/rest-typings': minor -'@rocket.chat/desktop-api': minor -'@rocket.chat/models': minor -'@rocket.chat/i18n': minor -'@rocket.chat/meteor': minor ---- - -## Phishing-Resistant Multi-Factor Authentication - -Introduces a more secure and reliable server-side OAuth authentication flow. - -### What’s New - -- **Improved OAuth login security** - OAuth authentication now happens fully on the server, reducing the risk of token theft, phishing attacks, and client-side credential interception. - -- **Built-in CSRF, state validation, and PKCE protection** - OAuth logins now include stronger protection against CSRF attacks, request tampering, and authorization code interception through secure state validation and PKCE support. - -- **Improved two-step verification with OAuth logins** - Users with email or TOTP two-factor authentication enabled will now be asked to complete 2FA even when signing in with providers like Google, GitHub, GitLab, and others. - -- **Improved mobile & desktop app login** - Mobile and desktop apps now support a smoother and more secure deep-link OAuth login flow. diff --git a/apps/meteor/app/2fa/server/code/EmailCheck.ts b/apps/meteor/app/2fa/server/code/EmailCheck.ts index c52531eaac781..5967aa518348b 100644 --- a/apps/meteor/app/2fa/server/code/EmailCheck.ts +++ b/apps/meteor/app/2fa/server/code/EmailCheck.ts @@ -10,7 +10,7 @@ import * as Mailer from '../../../mailer/server/api'; import { settings } from '../../../settings/server'; export class EmailCheck implements ICodeCheck { - public readonly name: string = 'email'; + public readonly name = 'email'; private getUserVerifiedEmails(user: IUser): string[] { if (!Array.isArray(user.emails)) { @@ -145,6 +145,6 @@ ${t('If_you_didnt_try_to_login_in_your_account_please_ignore_this_email')} public async maxFaildedAttemtpsReached(user: IUser) { const maxAttempts = settings.get('Accounts_TwoFactorAuthentication_Max_Invalid_Email_Code_Attempts'); - return Users.maxInvalidEmailCodeAttemptsReached(user._id, maxAttempts); + return (await Users.maxInvalidEmailCodeAttemptsReached(user._id, maxAttempts)) as boolean; } } diff --git a/apps/meteor/app/2fa/server/code/EmailCheckForOAuth.ts b/apps/meteor/app/2fa/server/code/EmailCheckForOAuth.ts deleted file mode 100644 index 68c3e8a05506d..0000000000000 --- a/apps/meteor/app/2fa/server/code/EmailCheckForOAuth.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { IUser } from '@rocket.chat/core-typings'; -import { TwoFactorChallenges } from '@rocket.chat/models'; - -import { EmailCheck } from './EmailCheck'; - -export class EmailCheckForOAuth extends EmailCheck { - public override readonly name = 'email-oauth'; - - public readonly method = 'email'; - - public async sendTwoFactorChallenge(user: IUser): Promise { - const challengeId = await TwoFactorChallenges.createTwoFactorChallenge(user._id, 'email'); - await this.sendEmailCode(user); - return challengeId; - } - - public async verifyEmailTwoFactorChallenge(user: IUser, challengeId: string, code: string): Promise { - const challenge = await TwoFactorChallenges.findOneByPendingChallengeId(challengeId); - if (!challenge) { - return false; - } - - if (challenge.expireAt && challenge.expireAt < new Date()) { - throw new Meteor.Error('error-challenge-expired', 'challenge expired'); - } - - const isCodeValid = await this.verify(user, code); - - if (!isCodeValid) { - return false; - } - - await TwoFactorChallenges.removeByPendingChallengeId(challengeId); - - return true; - } -} diff --git a/apps/meteor/app/2fa/server/code/TOTPCheck.ts b/apps/meteor/app/2fa/server/code/TOTPCheck.ts index ae9502569457f..236016883f89e 100644 --- a/apps/meteor/app/2fa/server/code/TOTPCheck.ts +++ b/apps/meteor/app/2fa/server/code/TOTPCheck.ts @@ -5,7 +5,7 @@ import { settings } from '../../../settings/server'; import { TOTP } from '../lib/totp'; export class TOTPCheck implements ICodeCheck { - public readonly name: string = 'totp'; + public readonly name = 'totp'; public isEnabled(user: IUser): boolean { if (!settings.get('Accounts_TwoFactorAuthentication_By_TOTP_Enabled')) { diff --git a/apps/meteor/app/2fa/server/code/TOTPCheckForOAuth.ts b/apps/meteor/app/2fa/server/code/TOTPCheckForOAuth.ts deleted file mode 100644 index 02e8267fb59eb..0000000000000 --- a/apps/meteor/app/2fa/server/code/TOTPCheckForOAuth.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { IUser } from '@rocket.chat/core-typings'; -import { TwoFactorChallenges } from '@rocket.chat/models'; - -import { TOTPCheck } from './TOTPCheck'; - -export class TOTPCheckForOAuth extends TOTPCheck { - public override readonly name = 'totp-oauth'; - - public readonly method = 'totp'; - - public async sendTwoFactorChallenge(user: IUser): Promise { - return TwoFactorChallenges.createTwoFactorChallenge(user._id, 'totp'); - } - - public async verifyEmailTwoFactorChallenge(user: IUser, challengeId: string, code: string): Promise { - const challenge = await TwoFactorChallenges.findOneByPendingChallengeId(challengeId); - if (!challenge) { - return false; - } - - if (challenge.expireAt && challenge.expireAt < new Date()) { - throw new Meteor.Error('error-challenge-expired', 'challenge expired'); - } - - const isCodeValid = await this.verify(user, code); - - if (!isCodeValid) { - return false; - } - - await TwoFactorChallenges.removeByPendingChallengeId(challengeId); - - return true; - } -} diff --git a/apps/meteor/app/2fa/server/code/index.ts b/apps/meteor/app/2fa/server/code/index.ts index 0094b25bd321d..8242b1c66185e 100644 --- a/apps/meteor/app/2fa/server/code/index.ts +++ b/apps/meteor/app/2fa/server/code/index.ts @@ -63,8 +63,8 @@ function getFingerprintFromConnection(connection: IMethodConnection): string { return crypto.createHash('md5').update(data).digest('hex'); } -export function getRememberDate(from: Date = new Date()): Date | undefined { - const rememberFor = settings.get('Accounts_TwoFactorAuthentication_RememberFor'); +function getRememberDate(from: Date = new Date()): Date | undefined { + const rememberFor = parseInt(settings.get('Accounts_TwoFactorAuthentication_RememberFor') as string, 10); if (rememberFor <= 0) { return; @@ -118,20 +118,6 @@ function isAuthorizedForToken(connection: IMethodConnection, user: IUser, option return true; } -export async function rememberAuthorizationByToken(token: string, userId: IUser['_id'], connection: IMethodConnection): Promise { - const user = await Users.findOneByIdAndLoginHashedToken(userId, token, { projection: { _id: 1, services: 1 } }); - if (!user) { - throw new Meteor.Error('error-user-not-found', 'user not found'); - } - - const expires = getRememberDate(); - if (!expires) { - return; - } - - await Users.setTwoFactorAuthorizationHashAndUntilForUserIdAndToken(user._id, token, getFingerprintFromConnection(connection), expires); -} - async function rememberAuthorization(connection: IMethodConnection, user: IUser): Promise { const currentToken = Accounts._getLoginToken(connection.id); @@ -160,7 +146,7 @@ interface ICheckCodeForUser { connection?: IMethodConnection; } -export const getSecondFactorMethod = (user: IUser, method: string | undefined, options: ITwoFactorOptions): ICodeCheck | undefined => { +const getSecondFactorMethod = (user: IUser, method: string | undefined, options: ITwoFactorOptions): ICodeCheck | undefined => { // try first getting one of the available methods or the one that was already provided const selectedMethod = getMethodByNameOrFirstActiveForUser(user, method); if (selectedMethod) { @@ -188,6 +174,7 @@ export async function checkCodeForUser({ user, code, method, options = {}, conne } let existingUser: IUser | null; + if (typeof user === 'string') { existingUser = await getUserForCheck(user); } else { diff --git a/apps/meteor/app/api/server/ApiClass.ts b/apps/meteor/app/api/server/ApiClass.ts index 62a2d7c0601c8..d51726049e0dd 100644 --- a/apps/meteor/app/api/server/ApiClass.ts +++ b/apps/meteor/app/api/server/ApiClass.ts @@ -144,7 +144,7 @@ const rateLimiterDictionary: Record< } > = {}; -export const generateConnection = ( +const generateConnection = ( ipAddress: string, httpHeaders: Record, ): { diff --git a/apps/meteor/app/api/server/index.ts b/apps/meteor/app/api/server/index.ts index c53c56d797cc5..5a6a6f06cbbab 100644 --- a/apps/meteor/app/api/server/index.ts +++ b/apps/meteor/app/api/server/index.ts @@ -45,7 +45,7 @@ import './v1/mailer'; import './v1/teams'; import './v1/moderation'; import './v1/uploads'; -import './v1/twoFactorChallenges'; + // This has to come last so all endpoints are registered before generating the OpenAPI documentation import './default/openApi'; diff --git a/apps/meteor/app/api/server/v1/twoFactorChallenges.ts b/apps/meteor/app/api/server/v1/twoFactorChallenges.ts deleted file mode 100644 index fd48e22ad7123..0000000000000 --- a/apps/meteor/app/api/server/v1/twoFactorChallenges.ts +++ /dev/null @@ -1,112 +0,0 @@ -import type { IMethodConnection } from '@rocket.chat/core-typings'; -import { TwoFactorChallenges } from '@rocket.chat/models'; -import { isTwoFactorChallengesSendEmailCodeParamsPOST, isTwoFactorChallengesVerifyChallengeParamsPOST } from '@rocket.chat/rest-typings'; -import { Accounts } from 'meteor/accounts-base'; - -import { emailCheckForOAuth, getTwoFAMethodForOAuth } from '../../../../server/lib/oauth/twoFactorAuth'; -import { getUserForCheck, rememberAuthorizationByToken } from '../../../2fa/server/code'; -import { generateConnection } from '../ApiClass'; -import { API } from '../api'; - -API.v1.addRoute( - 'twoFactorChallenges.sendEmailCode', - { validateParams: isTwoFactorChallengesSendEmailCodeParamsPOST, rateLimiterOptions: { intervalTimeInMS: 60000, numRequestsAllowed: 5 } }, - { - async post() { - const { challengeId } = this.bodyParams; - - if (!challengeId) { - throw new Meteor.Error('error-parameter-required', 'challengeId is required'); - } - - const challenge = await TwoFactorChallenges.findOneByPendingChallengeId(challengeId); - - if (!challenge) { - throw new Meteor.Error('error-challenge-not-found', 'challenge not found'); - } - - if (challenge.expireAt && challenge.expireAt < new Date()) { - throw new Meteor.Error('error-challenge-expired', 'challenge expired'); - } - - if (challenge.method !== 'email') { - throw new Meteor.Error('error-invalid-challenge-method', 'invalid challenge method'); - } - - const { userId } = challenge; - - const user = await getUserForCheck(userId); - - if (!user) { - throw new Meteor.Error('error-user-not-found', 'user not found'); - } - - await emailCheckForOAuth.sendEmailCode(user); - - return API.v1.success(); - }, - }, -); - -API.v1.addRoute( - 'twoFactorChallenges.verifyChallenge', - { - validateParams: isTwoFactorChallengesVerifyChallengeParamsPOST, - rateLimiterOptions: { intervalTimeInMS: 60000, numRequestsAllowed: 5 }, - }, - { - async post() { - const { challengeId, code } = this.bodyParams; - - if (!challengeId || !code) { - throw new Meteor.Error('error-parameter-required', 'challengeId and code are required'); - } - - const challenge = await TwoFactorChallenges.findOneByPendingChallengeId(challengeId); - - if (!challenge) { - throw new Meteor.Error('error-challenge-not-found', 'challenge not found'); - } - - const { userId } = challenge; - - const user = await getUserForCheck(userId); - - if (!user) { - throw new Meteor.Error('error-user-not-found', 'user not found'); - } - - const twoFAMethod = getTwoFAMethodForOAuth(challenge.method); - - const isCodeValid = await twoFAMethod.verifyEmailTwoFactorChallenge(user, challengeId, code); - - if (!isCodeValid) { - const tooManyAttempts = await twoFAMethod.maxFaildedAttemtpsReached(user); - if (tooManyAttempts) { - await TwoFactorChallenges.removeByPendingChallengeId(challengeId); - throw new Meteor.Error('totp-max-attempts', 'TOTP Maximun Failed Attempts Reached'); - } - return API.v1.failure('error-invalid-code', 'Invalid code'); - } - - const stampedToken = Accounts._generateStampedLoginToken(); - - await Accounts._insertLoginToken(user._id, stampedToken); - - const hashedToken = Accounts._hashLoginToken(stampedToken.token); - - const connection = { - ...generateConnection(this.requestIp, this.request.headers), - token: hashedToken, - } as unknown as IMethodConnection; - - // remember the 2FA authorization for the next requests - await rememberAuthorizationByToken(hashedToken, user._id, connection); - - return API.v1.success({ - loginToken: stampedToken.token, - userId: user._id, - }); - }, - }, -); diff --git a/apps/meteor/app/apple/lib/handleIdentityToken.ts b/apps/meteor/app/apple/lib/handleIdentityToken.ts index 861aa42707077..f3ab1c8f9e66f 100644 --- a/apps/meteor/app/apple/lib/handleIdentityToken.ts +++ b/apps/meteor/app/apple/lib/handleIdentityToken.ts @@ -27,7 +27,7 @@ async function isValidAppleJWT(identityToken: string, header: any): Promise> { +export async function handleIdentityToken(identityToken: string): Promise<{ id: string; email: string; name: string }> { const decodedToken = KJUR.jws.JWS.parse(identityToken); if (!(await isValidAppleJWT(identityToken, decodedToken.headerObj))) { @@ -38,14 +38,15 @@ export async function handleIdentityToken(identityToken: string): Promise { if (!enabled) { - passport.unuse('apple'); return ServiceConfiguration.configurations.removeAsync({ service: 'apple', }); @@ -48,96 +38,43 @@ settings.watchMultiple( return; } - passport.unuse('apple'); - - passport.use( - 'apple', - - new AppleStrategy( - { - clientID: settings.get('Accounts_OAuth_Apple_id'), - teamID: settings.get('Accounts_OAuth_Apple_iss'), - keyID: settings.get('Accounts_OAuth_Apple_kid'), - privateKeyString: settings.get('Accounts_OAuth_Apple_secretKey').replace(/\\n/g, '\n'), - callbackURL: `${settings.get('Site_Url')}/_oauth/apple`, - scope: ['name', 'email'], - passReqToCallback: false, - state: false, - }, - async (accessToken: string, refreshToken: string, idToken: string, profile: Profile, done) => { - try { - const serviceData = await handleIdentityToken(idToken); - if (profile?.name) { - serviceData.name = `${profile.name.firstName}${profile.name.middleName ? ` ${profile.name.middleName}` : ''}${ - profile.name.lastName ? ` ${profile.name.lastName}` : '' - }`; - } - - if (!serviceData.email && profile?.email) { - serviceData.email = profile.email; - } - - const user = await Accounts.updateOrCreateUserFromExternalService( - 'apple', - { - accessToken, - refreshToken, - ...serviceData, - }, - {}, - ); - - if (!user?.userId || typeof user?.userId !== 'string') { - return done(new Error('User not found')); - } - - const userFromDB = await Users.findOneById(user.userId); - - if (!userFromDB) { - return done(new Error('User not found')); - } - - return done(null, userFromDB); - } catch (error: any) { - done(error); - return { - type: 'apple', - error: new MeteorError(Accounts.LoginCancelledError.numericError, error.message), - }; - } - }, - ), + const HEADER = { + kid, + alg: 'ES256', + }; + + const now = new Date(); + const exp = new Date(); + exp.setMonth(exp.getMonth() + 5); // from Apple docs expiration time must no be greater than 6 months + + const secret = KJUR.jws.JWS.sign( + null, + HEADER, + { + iss, + iat: Math.floor(now.getTime() / 1000), + exp: Math.floor(exp.getTime() / 1000), + aud: 'https://appleid.apple.com', + sub: clientId, + }, + serverSecret as string, ); - const callbackHandler = [ - express.urlencoded({ extended: true }), - passport.authenticate('apple', { failWithError: true, session: true, keepSessionInfo: true }), - passportOAuthCallback(settings.get('Site_Url')), - ]; - - oAuthRouter.get( - '/oauth/apple', - (req, _res, next) => { - const { loginClient } = req.query; - if (loginClient === 'mobile' || loginClient === 'desktop') { - req.session.loginClient = loginClient; - req.session.save(() => { - next(); - }); - } else { - //delete stale value from previous sessions if any - delete req.session.loginClient; - next(); - } + await ServiceConfiguration.configurations.upsertAsync( + { + service: 'apple', + }, + { + $set: { + showButton: true, + secret, + enabled: settings.get('Accounts_OAuth_Apple'), + loginStyle: 'popup', + clientId: clientId as string, + buttonColor: '#000', + buttonLabelColor: '#FFF', + }, }, - passport.authenticate('apple', { - scope: ['name', 'email'], - }), ); - - oAuthRouter - .route('/_oauth/apple') - .post(...callbackHandler) - .get(...callbackHandler); }, ); diff --git a/apps/meteor/app/apple/server/loginHandler.ts b/apps/meteor/app/apple/server/loginHandler.ts index dcb17fa06aff9..18ac7ddd75268 100644 --- a/apps/meteor/app/apple/server/loginHandler.ts +++ b/apps/meteor/app/apple/server/loginHandler.ts @@ -29,10 +29,10 @@ Accounts.registerLoginHandler('apple', async (loginRequest) => { profile.name = `${givenName} ${familyName}`; } - const result = await Accounts.updateOrCreateUserFromExternalService('apple', serviceData, { profile }); + const result = Accounts.updateOrCreateUserFromExternalService('apple', serviceData, { profile }); // Ensure processing succeeded - if (result?.userId === undefined) { + if (result === undefined || result.userId === undefined) { return { type: 'apple', error: new Meteor.Error(Accounts.LoginCancelledError.numericError, 'User creation failed from Apple response token'), diff --git a/apps/meteor/app/custom-oauth/server/customOAuth.ts b/apps/meteor/app/custom-oauth/server/customOAuth.ts deleted file mode 100644 index 13c4d4d6ca373..0000000000000 --- a/apps/meteor/app/custom-oauth/server/customOAuth.ts +++ /dev/null @@ -1,393 +0,0 @@ -import { LDAP } from '@rocket.chat/core-services'; -import type { IUser, OAuthConfiguration } from '@rocket.chat/core-typings'; -import { Logger } from '@rocket.chat/logger'; -import { Users } from '@rocket.chat/models'; -import { isAbsoluteURL } from '@rocket.chat/tools'; -import { Accounts } from 'meteor/accounts-base'; -import type { DoneCallback } from 'passport'; -import type { VerifyFunction, StrategyOptions } from 'passport-oauth2'; -import { Strategy } from 'passport-oauth2'; - -import { normalizers, fromTemplate, renameInvalidProperties } from './transform_helpers'; -import { client } from '../../../server/database/utils'; -import { callbacks } from '../../../server/lib/callbacks'; -import { saveUserIdentity } from '../../lib/server/functions/saveUserIdentity'; -import { notifyOnUserChange } from '../../lib/server/lib/notifyListener'; -import { settings } from '../../settings/server/cached'; - -const logger = new Logger('CustomOAuth'); -const BeforeUpdateOrCreateUserFromExternalService: ((serviceName: string, serviceData: Record) => Promise)[] = []; - -export class CustomOAuthStrategy extends Strategy { - options: StrategyOptions; - - config: OAuthConfiguration & { clientSecret: string }; - - identityPath: string; - - emailPath: string; - - tokenSentVia: string; - - identityTokenSentVia: string; - - keyField: string; - - usernameField: string; - - emailField: string; - - nameField: string; - - avatarField: string; - - mergeUsers: boolean; - - mergeUsersDistinctServices: boolean; - - rolesClaim: string; - - accessTokenParam: string; - - serverURL: string; - - tokenPath: string; - - constructor(name: string, config: OAuthConfiguration & { clientSecret: string }, verify: VerifyFunction) { - if (!config.serverURL || typeof config.serverURL !== 'string') { - throw new Meteor.Error('customOAuth: serverURL is required and must be String'); - } - - const options: StrategyOptions = { - authorizationURL: config.serverURL + config.authorizePath, - tokenURL: config.serverURL + config.tokenPath, - clientID: config.clientId, - clientSecret: config.clientSecret, - callbackURL: `${settings.get('Site_Url')}/_oauth/${name}`, - state: true, - pkce: config.pkce ?? true, - scope: config.scope, - }; - - super(options, verify); - - this.serverURL = config.serverURL; - this.tokenPath = config.tokenPath || '/oauth/token'; - this.identityPath = config.identityPath || '/me'; - this.tokenSentVia = config.tokenSentVia; - this.identityTokenSentVia = config.identityTokenSentVia; - this.keyField = config.keyField; - this.usernameField = config.usernameField?.trim(); - this.emailField = config.emailField?.trim(); - this.nameField = config.nameField?.trim(); - this.avatarField = config.avatarField?.trim(); - this.mergeUsers = !!config.mergeUsers; - this.mergeUsersDistinctServices = !!config.mergeUsersDistinctServices; - this.rolesClaim = config.rolesClaim || 'roles'; - this.accessTokenParam = config.accessTokenParam || 'access_token'; - - if (this.identityTokenSentVia == null || this.identityTokenSentVia === 'default') { - this.identityTokenSentVia = this.tokenSentVia; - } - - if (!isAbsoluteURL(this.tokenPath)) { - this.tokenPath = this.serverURL + this.tokenPath; - } - - if (!isAbsoluteURL(this.identityPath)) { - this.identityPath = this.serverURL + this.identityPath; - } - - if (this.emailPath && !isAbsoluteURL(this.emailPath)) { - this.emailPath = this.serverURL + this.emailPath; - } - - if (config.tokenSentVia === 'header') { - this._oauth2.useAuthorizationHeaderforGET(true); - } - - if (config.accessTokenParam && config.accessTokenParam !== 'access_token') { - this._oauth2.setAccessTokenName(config.accessTokenParam); - } - - if (config.addAutopublishFields && typeof config.addAutopublishFields === 'object') { - Accounts.addAutopublishFields(config.addAutopublishFields); - } - - this.name = name; - this.options = options; - this.config = config; - - this.addHookToProcessUser(); - } - - getUsername(data: Record) { - try { - const value = fromTemplate(this.usernameField, data); - - if (!value) { - logger.debug({ msg: 'Username field not found in data', usernameField: this.usernameField, data }); - throw new Meteor.Error('field_not_found', `Username field "${this.usernameField}" not found in data`); - } - - return value as string; - } catch (error) { - throw new Error('CustomOAuth: Failed to extract username', { cause: error }); - } - } - - getEmail(data: Record) { - try { - const value = fromTemplate(this.emailField, data); - - if (!value) { - logger.debug({ msg: 'Email field not found in data', emailField: this.emailField, data }); - throw new Meteor.Error('field_not_found', `Email field "${this.emailField}" not found in data`); - } - return value as string; - } catch (error) { - throw new Error('CustomOAuth: Failed to extract email', { cause: error }); - } - } - - getCustomName(data: Record) { - try { - const value = fromTemplate(this.nameField, data); - - if (!value) { - return this.getName(data); - } - - return value as string; - } catch (error) { - throw new Error('CustomOAuth: Failed to extract custom name', { cause: error }); - } - } - - getAvatarUrl(data: Record) { - try { - const value = fromTemplate(this.avatarField, data); - - if (!value) { - logger.debug({ msg: 'Avatar field not found in data', avatarField: this.avatarField, data }); - } - return value as string; - } catch (error) { - throw new Error('CustomOAuth: Failed to extract avatar url', { cause: error }); - } - } - - getName(identity: Record): string { - const name = (identity.name || - identity.username || - identity.nickname || - identity.CharacterName || - identity.userName || - identity.preferred_username || - identity.user?.name) as string; - return name; - } - - normalizeIdentity(identity: Record) { - if (identity) { - for (const normalizer of Object.values(normalizers)) { - const result = normalizer(identity); - if (result) { - identity = result; - } - } - } - - if (this.usernameField) { - identity.username = this.getUsername(identity); - } - - if (this.emailField) { - identity.email = this.getEmail(identity); - } - - if (this.avatarField) { - identity.avatarUrl = this.getAvatarUrl(identity); - } - - if (this.nameField) { - identity.name = this.getCustomName(identity); - } else { - identity.name = this.getName(identity); - } - - return renameInvalidProperties(identity); - } - - override userProfile(accessToken: string, done: DoneCallback) { - if (!this.identityPath) { - return done(new Error(`identityPath is required for ${this.name} custom oauth`)); - } - - this._oauth2.get(this.identityPath, accessToken, (err, body, res) => { - if (err) { - return done(err); - } - - if ((res && res.statusCode !== 200) || !body) { - return done(new Error(`Failed to fetch identity from ${this.name} at ${this.identityPath}}`)); - } - - try { - const result = JSON.parse(typeof body === 'string' ? body : body.toString()); - const normalizedIdentity = this.normalizeIdentity(result); - //Nextcloud URL needed on addWebdavServer - normalizedIdentity.serverURL = this.serverURL; - return done(null, normalizedIdentity); - } catch (e) { - return done(new Error(`Failed to parse identity from ${this.name} at ${this.identityPath}. ${e}`)); - } - }); - } - - addHookToProcessUser() { - BeforeUpdateOrCreateUserFromExternalService.push(async (serviceName, serviceData) => { - if (serviceName !== this.name) { - return; - } - - if (serviceData.username) { - let user: IUser | null = null; - - if (this.keyField === 'username') { - user = this.mergeUsersDistinctServices - ? await Users.findOneByUsernameIgnoringCase(serviceData.username) - : await Users.findOneByUsernameAndServiceNameIgnoringCase(serviceData.username, serviceData.id, serviceName); - } else if (this.keyField === 'email') { - user = this.mergeUsersDistinctServices - ? await Users.findOneByEmailAddress(serviceData.email) - : await Users.findOneByEmailAddressAndServiceNameIgnoringCase(serviceData.email, serviceData.id, serviceName); - } - - if (!user) { - return; - } - - await callbacks.run('afterProcessOAuthUser', { serviceName, serviceData, user }); - // User already created or merged and has identical name as before - if ( - user.services?.[serviceName as keyof NonNullable] && - user.services[serviceName as keyof NonNullable].id === serviceData.id && - user.name === serviceData.name && - (this.keyField === 'email' || !serviceData.email || user.emails?.find(({ address }) => address === serviceData.email)) - ) { - return; - } - - if (this.mergeUsers !== true) { - throw new Meteor.Error('CustomOAuth', `User with username ${user.username} already exists`); - } - - const serviceIdKey = `services.${serviceName}.id`; - const successCallbacks = [ - async () => { - const updatedUser = await Users.findOneById(user._id, { projection: { name: 1, emails: 1, [serviceIdKey]: 1 } }); - if (updatedUser) { - const { _id, ...diff } = updatedUser; - void notifyOnUserChange({ clientAction: 'updated', id: user._id, diff }); - } - }, - ]; - - const session = client.startSession(); - try { - // Extend the session to match the ExtendedSession type expected by saveUserIdentity - Object.assign(session, { - onceSuccesfulCommit: (cb: () => Promise) => { - successCallbacks.push(cb); - }, - }); - - session.startTransaction(); - - const updater = Users.getUpdater(); - - if (this.keyField === 'username' && serviceData.email) { - updater.set('emails', [{ address: serviceData.email, verified: true }]); - } - - updater.set(serviceIdKey as keyof IUser['services'], serviceData.id); - - await saveUserIdentity({ - _id: user._id, - name: serviceData.name, - updater, - session, - updateUsernameInBackground: true, - // Username needs to be included otherwise the name won't be updated in some collections - username: user.username, - }); - await Users.updateFromUpdater({ _id: user._id }, updater, { session }); - - await session.commitTransaction(); - } catch (e) { - await session.abortTransaction(); - throw e; - } finally { - await session.endSession(); - } - - void Promise.allSettled(successCallbacks.map((cb) => cb())); - } - }); - - Accounts.validateNewUser((user: IUser & { email: string }) => { - if (!user.services?.[this.name as keyof NonNullable]?.id) { - return true; - } - - if (this.usernameField) { - user.username = user.services[this.name as keyof NonNullable].username; - } - - if (this.emailField) { - user.email = user.services[this.name as keyof NonNullable].email; - } - - if (this.nameField) { - user.name = user.services[this.name as keyof NonNullable].name; - } - - return true; - }); - } -} - -const { updateOrCreateUserFromExternalService } = Accounts; - -Accounts.updateOrCreateUserFromExternalService = async function (...args) { - for (const hook of BeforeUpdateOrCreateUserFromExternalService) { - await hook.apply(this, args as unknown as [string, Record]); - } - - const [serviceName, serviceData] = args; - - const user = await updateOrCreateUserFromExternalService.apply(this, args); - - if (!user?.userId) { - return undefined; - } - - const fullUser = await Users.findOneById(user.userId as string); - - if (!fullUser) { - return undefined; - } - - if (settings.get('LDAP_Update_Data_On_OAuth_Login') && fullUser.username) { - await LDAP.loginAuthenticatedUserRequest(fullUser.username); - } - - await callbacks.run('afterValidateNewOAuthUser', { - identity: serviceData, - serviceName, - user: fullUser, - }); - - return user; -}; diff --git a/apps/meteor/app/custom-oauth/server/custom_oauth_server.js b/apps/meteor/app/custom-oauth/server/custom_oauth_server.js index aea366ce4e96c..40495449e8b22 100644 --- a/apps/meteor/app/custom-oauth/server/custom_oauth_server.js +++ b/apps/meteor/app/custom-oauth/server/custom_oauth_server.js @@ -23,9 +23,6 @@ const logger = new Logger('CustomOAuth'); const Services = {}; const BeforeUpdateOrCreateUserFromExternalService = []; -/** - * @deprecated in favor of new Passport OAuth implementation. - */ export class CustomOAuth { constructor(name, options) { logger.debug({ msg: 'Init CustomOAuth', name, options }); diff --git a/apps/meteor/app/dolphin/server/lib.ts b/apps/meteor/app/dolphin/server/lib.ts index 77622fe43edab..826a277b81e4e 100644 --- a/apps/meteor/app/dolphin/server/lib.ts +++ b/apps/meteor/app/dolphin/server/lib.ts @@ -1,15 +1,13 @@ -import type { IUser, OAuthConfiguration } from '@rocket.chat/core-typings'; +import type { IUser } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; import { ServiceConfiguration } from 'meteor/service-configuration'; -import passport from 'passport'; -import _ from 'underscore'; import { callbacks } from '../../../server/lib/callbacks'; import { beforeCreateUserCallback } from '../../../server/lib/callbacks/beforeCreateUserCallback'; -import { addPassportCustomOAuth } from '../../../server/lib/oauth/addPassportCustomOAuth'; +import { CustomOAuth } from '../../custom-oauth/server/custom_oauth_server'; import { settings } from '../../settings/server'; -const config: Partial = { +const config = { serverURL: '', authorizePath: '/m/oauth2/auth/', tokenPath: '/m/oauth2/token/', @@ -22,6 +20,8 @@ const config: Partial = { accessTokenParam: 'access_token', }; +const Dolphin = new CustomOAuth('dolphin', config); + function DolphinOnCreateUser(options: any, user?: IUser) { // TODO: callbacks Fix this if (user?.services?.dolphin?.NickName) { @@ -30,32 +30,11 @@ function DolphinOnCreateUser(options: any, user?: IUser) { return options; } -const configureDolphinOAuth = () => { - passport.unuse('dolphin'); - - const enabled = settings.get('Accounts_OAuth_Dolphin'); - if (!enabled) { - return; - } - - const serverURL = settings.get('Accounts_OAuth_Dolphin_URL').trim().replace(/\/*$/, ''); - const clientId = settings.get('Accounts_OAuth_Dolphin_id'); - const clientSecret = settings.get('Accounts_OAuth_Dolphin_secret'); - - if (!clientId || !clientSecret || !serverURL) { - return; - } - - addPassportCustomOAuth('dolphin', { ...config, serverURL, clientId, clientSecret }); -}; - Meteor.startup(async () => { - const updateConfig = () => _.debounce(configureDolphinOAuth, 300); - - settings.watchMultiple( - ['Accounts_OAuth_Dolphin', 'Accounts_OAuth_Dolphin_URL', 'Accounts_OAuth_Dolphin_id', 'Accounts_OAuth_Dolphin_secret'], - updateConfig, - ); + settings.watch('Accounts_OAuth_Dolphin_URL', (value) => { + config.serverURL = value; + return Dolphin.configure(config); + }); if (settings.get('Accounts_OAuth_Dolphin_URL')) { const data = { diff --git a/apps/meteor/app/drupal/server/lib.ts b/apps/meteor/app/drupal/server/lib.ts index a4f71bd81696f..d137551fb8377 100644 --- a/apps/meteor/app/drupal/server/lib.ts +++ b/apps/meteor/app/drupal/server/lib.ts @@ -1,12 +1,14 @@ -import type { OAuthConfiguration } from '@rocket.chat/core-typings'; +import type { OauthConfig } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; -import passport from 'passport'; -import _ from 'underscore'; -import { addPassportCustomOAuth } from '../../../server/lib/oauth/addPassportCustomOAuth'; +import { CustomOAuth } from '../../custom-oauth/server/custom_oauth_server'; import { settings } from '../../settings/server'; -const config: Partial = { +// Drupal Server CallBack URL needs to be http(s)://{rocketchat.server}[:port]/_oauth/drupal +// In RocketChat -> Administration the URL needs to be http(s)://{drupal.server}/ + +const config: OauthConfig = { + serverURL: '', identityPath: '/oauth2/UserInfo', authorizePath: '/oauth2/authorize', tokenPath: '/oauth2/token', @@ -21,29 +23,11 @@ const config: Partial = { accessTokenParam: 'access_token', }; -const configureDrupalOAuth = () => { - passport.unuse('drupal'); - const enabled = settings.get('Accounts_OAuth_Drupal'); - if (!enabled) { - return; - } - - const serverURL = settings.get('API_Drupal_URL').trim().replace(/\/*$/, ''); - const clientId = settings.get('Accounts_OAuth_Drupal_id'); - const clientSecret = settings.get('Accounts_OAuth_Drupal_secret'); - - if (!clientId || !clientSecret || !serverURL) { - return; - } - - addPassportCustomOAuth('drupal', { ...config, serverURL, clientId, clientSecret }); -}; +const Drupal = new CustomOAuth('drupal', config); Meteor.startup(() => { - const updateConfig = _.debounce(configureDrupalOAuth, 300); - - settings.watchMultiple( - ['Accounts_OAuth_Drupal', 'API_Drupal_URL', 'Accounts_OAuth_Drupal_id', 'Accounts_OAuth_Drupal_secret'], - updateConfig, - ); + settings.watch('API_Drupal_URL', (value) => { + config.serverURL = value; + Drupal.configure(config); + }); }); diff --git a/apps/meteor/app/gitlab/server/lib.ts b/apps/meteor/app/gitlab/server/lib.ts index 3df1c1ded5496..0f4d330cf5970 100644 --- a/apps/meteor/app/gitlab/server/lib.ts +++ b/apps/meteor/app/gitlab/server/lib.ts @@ -1,12 +1,11 @@ -import type { OAuthConfiguration } from '@rocket.chat/core-typings'; +import type { OauthConfig } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; -import passport from 'passport'; import _ from 'underscore'; -import { addPassportCustomOAuth } from '../../../server/lib/oauth/addPassportCustomOAuth'; +import { CustomOAuth } from '../../custom-oauth/server/custom_oauth_server'; import { settings } from '../../settings/server'; -const config: Partial = { +const config: OauthConfig = { serverURL: 'https://gitlab.com', identityPath: '/api/v4/user', scope: 'read_user', @@ -18,39 +17,15 @@ const config: Partial = { accessTokenParam: 'access_token', }; -const configureGitlabOAuth = () => { - passport.unuse('gitlab'); - - const enabled = settings.get('Accounts_OAuth_Gitlab'); - if (!enabled) { - return; - } - - const clientId = settings.get('Accounts_OAuth_Gitlab_id'); - const clientSecret = settings.get('Accounts_OAuth_Gitlab_secret'); - const serverURL = settings.get('API_Gitlab_URL').trim().replace(/\/*$/, '') || config.serverURL; - const identityPath = settings.get('Accounts_OAuth_Gitlab_identity_path') || config.identityPath; - const mergeUsers = Boolean(settings.get('Accounts_OAuth_Gitlab_merge_users')); - - if (!clientId || !clientSecret) { - return; - } - - addPassportCustomOAuth('gitlab', { ...config, clientId, clientSecret, serverURL, identityPath, mergeUsers }); -}; +const Gitlab = new CustomOAuth('gitlab', config); Meteor.startup(() => { - const updateConfig = _.debounce(configureGitlabOAuth, 300); - - settings.watchMultiple( - [ - 'Accounts_OAuth_Gitlab', - 'API_Gitlab_URL', - 'Accounts_OAuth_Gitlab_id', - 'Accounts_OAuth_Gitlab_secret', - 'Accounts_OAuth_Gitlab_identity_path', - 'Accounts_OAuth_Gitlab_merge_users', - ], - updateConfig, - ); + const updateConfig = _.debounce(() => { + config.serverURL = settings.get('API_Gitlab_URL').trim().replace(/\/*$/, '') || config.serverURL; + config.identityPath = settings.get('Accounts_OAuth_Gitlab_identity_path') || config.identityPath; + config.mergeUsers = Boolean(settings.get('Accounts_OAuth_Gitlab_merge_users')); + Gitlab.configure(config); + }, 300); + + settings.watchMultiple(['API_Gitlab_URL', 'Accounts_OAuth_Gitlab_identity_path', 'Accounts_OAuth_Gitlab_merge_users'], updateConfig); }); diff --git a/apps/meteor/app/lib/server/methods/createToken.ts b/apps/meteor/app/lib/server/methods/createToken.ts index 6e8e2538ab522..63de5b98f4210 100644 --- a/apps/meteor/app/lib/server/methods/createToken.ts +++ b/apps/meteor/app/lib/server/methods/createToken.ts @@ -16,7 +16,7 @@ export async function generateAccessToken(userId: string, secret: string) { } const token = Accounts._generateStampedLoginToken(); - await Accounts._insertLoginToken(userId, token); + Accounts._insertLoginToken(userId, token); await User.ensureLoginTokensLimit(userId); diff --git a/apps/meteor/app/linkedin/server/index.ts b/apps/meteor/app/linkedin/server/index.ts deleted file mode 100644 index cf327e4971bb2..0000000000000 --- a/apps/meteor/app/linkedin/server/index.ts +++ /dev/null @@ -1 +0,0 @@ -import './lib'; diff --git a/apps/meteor/app/linkedin/server/lib.ts b/apps/meteor/app/linkedin/server/lib.ts deleted file mode 100644 index 6fb0d450d2fda..0000000000000 --- a/apps/meteor/app/linkedin/server/lib.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { OAuthConfiguration } from '@rocket.chat/core-typings'; -import { Meteor } from 'meteor/meteor'; -import passport from 'passport'; - -import { addPassportCustomOAuth } from '../../../server/lib/oauth/addPassportCustomOAuth'; -import { settings } from '../../settings/server'; - -const config: Partial = { - serverURL: 'https://www.linkedin.com', - authorizePath: '/oauth/v2/authorization', - tokenPath: '/oauth/v2/accessToken', - identityPath: 'https://api.linkedin.com/v2/userinfo', - scope: 'openid email profile', - tokenSentVia: 'header', - addAutopublishFields: { - forLoggedInUser: ['services.linkedin'], - forOtherUsers: ['services.linkedin.name'], - }, - pkce: false, - emailField: 'email', - avatarField: 'picture', -}; - -const serviceKey = 'linkedin'; - -const configureLinkedInOAuth = (): void => { - passport.unuse(serviceKey); - - const enabled = settings.get('Accounts_OAuth_Linkedin'); - if (!enabled) { - return; - } - - const clientId = settings.get('Accounts_OAuth_Linkedin_id'); - const clientSecret = settings.get('Accounts_OAuth_Linkedin_secret'); - - if (!clientId || !clientSecret) { - return; - } - - addPassportCustomOAuth(serviceKey, { ...config, clientId, clientSecret }); -}; - -Meteor.startup(() => { - settings.watchMultiple( - ['Accounts_OAuth_Linkedin', 'Accounts_OAuth_Linkedin_id', 'Accounts_OAuth_Linkedin_secret'], - configureLinkedInOAuth, - ); -}); diff --git a/apps/meteor/app/meteor-developer/server/index.ts b/apps/meteor/app/meteor-developer/server/index.ts deleted file mode 100644 index cf327e4971bb2..0000000000000 --- a/apps/meteor/app/meteor-developer/server/index.ts +++ /dev/null @@ -1 +0,0 @@ -import './lib'; diff --git a/apps/meteor/app/meteor-developer/server/lib.ts b/apps/meteor/app/meteor-developer/server/lib.ts deleted file mode 100644 index cb532732ead92..0000000000000 --- a/apps/meteor/app/meteor-developer/server/lib.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { OAuthConfiguration } from '@rocket.chat/core-typings'; -import { Meteor } from 'meteor/meteor'; -import passport from 'passport'; - -import { addPassportCustomOAuth } from '../../../server/lib/oauth/addPassportCustomOAuth'; -import { settings } from '../../settings/server'; - -const config: Partial = { - serverURL: 'https://www.meteor.com', - authorizePath: '/oauth2/authorize', - tokenPath: '/oauth2/token', - identityPath: '/api/v1/identity', - scope: 'email', - tokenSentVia: 'header', - addAutopublishFields: { - forLoggedInUser: ['services.meteor-developer'], - forOtherUsers: ['services.meteor-developer.username'], - }, -}; - -const serviceKey = 'meteor-developer'; - -const configureMeteorDeveloperOAuth = (): void => { - passport.unuse(serviceKey); - - const enabled = settings.get('Accounts_OAuth_Meteor'); - if (!enabled) { - return; - } - - const clientId = settings.get('Accounts_OAuth_Meteor_id'); - const clientSecret = settings.get('Accounts_OAuth_Meteor_secret'); - - if (!clientId || !clientSecret) { - return; - } - - addPassportCustomOAuth(serviceKey, { ...config, clientId, clientSecret }); -}; - -Meteor.startup(() => { - settings.watchMultiple( - ['Accounts_OAuth_Meteor', 'Accounts_OAuth_Meteor_id', 'Accounts_OAuth_Meteor_secret'], - configureMeteorDeveloperOAuth, - ); -}); diff --git a/apps/meteor/app/nextcloud/server/lib.ts b/apps/meteor/app/nextcloud/server/lib.ts index fc2b592b7c1ad..28cf52da57a17 100644 --- a/apps/meteor/app/nextcloud/server/lib.ts +++ b/apps/meteor/app/nextcloud/server/lib.ts @@ -1,12 +1,14 @@ -import type { OAuthConfiguration } from '@rocket.chat/core-typings'; +import type { OauthConfig } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; -import { addPassportCustomOAuth } from '../../../server/lib/oauth/addPassportCustomOAuth'; -import { settings } from '../../settings/server/cached'; +import { CustomOAuth } from '../../custom-oauth/server/custom_oauth_server'; +import { settings } from '../../settings/server'; -const NEXTCLOUD_PATHS = { +const config: OauthConfig = { + serverURL: '', tokenPath: '/index.php/apps/oauth2/api/v1/token', - tokenSentVia: 'header' as OAuthConfiguration['tokenSentVia'], + tokenSentVia: 'header', authorizePath: '/index.php/apps/oauth2/authorize', identityPath: '/ocs/v2.php/cloud/user?format=json', scope: 'openid', @@ -16,31 +18,20 @@ const NEXTCLOUD_PATHS = { }, }; -function configureNextcloudOAuth(): void { - const enabled = settings.get('Accounts_OAuth_Nextcloud'); - if (!enabled) { - return; - } +const Nextcloud = new CustomOAuth('nextcloud', config); - const serverURL = settings.get('Accounts_OAuth_Nextcloud_URL')?.trim().replace(/\/*$/, ''); - const clientId = settings.get('Accounts_OAuth_Nextcloud_id'); - const clientSecret = settings.get('Accounts_OAuth_Nextcloud_secret'); - - if (!serverURL || !clientId || !clientSecret) { +const fillServerURL = _.debounce((): void => { + const nextcloudURL = settings.get('Accounts_OAuth_Nextcloud_URL'); + if (!nextcloudURL) { + if (nextcloudURL === undefined) { + return fillServerURL(); + } return; } - - addPassportCustomOAuth('nextcloud', { - ...NEXTCLOUD_PATHS, - serverURL, - clientId, - clientSecret, - }); -} + config.serverURL = nextcloudURL.trim().replace(/\/*$/, ''); + return Nextcloud.configure(config); +}, 1000); Meteor.startup(() => { - settings.watchMultiple( - ['Accounts_OAuth_Nextcloud', 'Accounts_OAuth_Nextcloud_URL', 'Accounts_OAuth_Nextcloud_id', 'Accounts_OAuth_Nextcloud_secret'], - configureNextcloudOAuth, - ); + settings.watch('Accounts_OAuth_Nextcloud_URL', () => fillServerURL()); }); diff --git a/apps/meteor/app/wordpress/server/lib.ts b/apps/meteor/app/wordpress/server/lib.ts index eb1ce2c01ceb2..7777ea1382215 100644 --- a/apps/meteor/app/wordpress/server/lib.ts +++ b/apps/meteor/app/wordpress/server/lib.ts @@ -1,13 +1,12 @@ -import type { OAuthConfiguration } from '@rocket.chat/core-typings'; +import type { OauthConfig } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; import { ServiceConfiguration } from 'meteor/service-configuration'; -import passport from 'passport'; import _ from 'underscore'; -import { addPassportCustomOAuth } from '../../../server/lib/oauth/addPassportCustomOAuth'; +import { CustomOAuth } from '../../custom-oauth/server/custom_oauth_server'; import { settings } from '../../settings/server'; -const config: Partial = { +const config: OauthConfig = { serverURL: '', identityPath: '/oauth/me', @@ -18,7 +17,7 @@ const config: Partial = { accessTokenParam: 'access_token', }; -const serviceKey = 'wordpress'; +const WordPress = new CustomOAuth('wordpress', config); const fillSettings = _.debounce(async (): Promise => { config.serverURL = settings.get('API_Wordpress_URL'); @@ -29,7 +28,11 @@ const fillSettings = _.debounce(async (): Promise => { return; } - passport.unuse(serviceKey); + delete config.identityPath; + delete config.identityTokenSentVia; + delete config.authorizePath; + delete config.tokenPath; + delete config.scope; const serverType = settings.get('Accounts_OAuth_Wordpress_server_type'); switch (serverType) { @@ -56,7 +59,7 @@ const fillSettings = _.debounce(async (): Promise => { break; case 'wordpress-com': config.identityPath = 'https://public-api.wordpress.com/rest/v1/me'; - config.identityTokenSentVia = 'header' as OAuthConfiguration['identityTokenSentVia']; + config.identityTokenSentVia = 'header'; config.authorizePath = 'https://public-api.wordpress.com/oauth2/authorize'; config.tokenPath = 'https://public-api.wordpress.com/oauth2/token'; config.scope = 'auth'; @@ -66,13 +69,12 @@ const fillSettings = _.debounce(async (): Promise => { break; } - addPassportCustomOAuth(serviceKey, config); - + const result = WordPress.configure(config); const enabled = settings.get('Accounts_OAuth_Wordpress'); if (enabled) { await ServiceConfiguration.configurations.upsertAsync( { - service: serviceKey, + service: 'wordpress', }, { $set: config, @@ -80,9 +82,11 @@ const fillSettings = _.debounce(async (): Promise => { ); } else { await ServiceConfiguration.configurations.removeAsync({ - service: serviceKey, + service: 'wordpress', }); } + + return result; }, 1000); Meteor.startup(() => { diff --git a/apps/meteor/client/components/TwoFactorModal/TwoFactorEmailModal.tsx b/apps/meteor/client/components/TwoFactorModal/TwoFactorEmailModal.tsx index 14f86a832e075..c80fb09e78ae1 100644 --- a/apps/meteor/client/components/TwoFactorModal/TwoFactorEmailModal.tsx +++ b/apps/meteor/client/components/TwoFactorModal/TwoFactorEmailModal.tsx @@ -1,7 +1,7 @@ import { Box, Button } from '@rocket.chat/fuselage'; import { FieldGroup, TextInput, Field, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage-forms'; import { GenericModal } from '@rocket.chat/ui-client'; -import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import { useForm, Controller } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -12,14 +12,14 @@ import { Method } from './TwoFactorModal'; type TwoFactorEmailModalProps = { onConfirm: OnConfirm; onClose: () => void; - resendEmail?: () => Promise; + emailOrUsername: string; }; type TwoFactorEmailFormData = { code: string; }; -const TwoFactorEmailModal = ({ onConfirm, onClose, resendEmail }: TwoFactorEmailModalProps): ReactElement => { +const TwoFactorEmailModal = ({ onConfirm, onClose, emailOrUsername }: TwoFactorEmailModalProps): ReactElement => { const dispatchToastMessage = useToastMessageDispatch(); const { t } = useTranslation(); @@ -33,12 +33,11 @@ const TwoFactorEmailModal = ({ onConfirm, onClose, resendEmail }: TwoFactorEmail defaultValues: { code: '' }, }); + const sendEmailCode = useEndpoint('POST', '/v1/users.2fa.sendEmailCode'); + const onClickResendCode = async (): Promise => { try { - if (!resendEmail) { - throw new Error('resendEmail is not defined'); - } - await resendEmail(); + await sendEmailCode({ emailOrUsername }); dispatchToastMessage({ type: 'success', message: t('Email_sent') }); } catch (error) { dispatchToastMessage({ diff --git a/apps/meteor/client/components/TwoFactorModal/TwoFactorModal.tsx b/apps/meteor/client/components/TwoFactorModal/TwoFactorModal.tsx index 04f603882c531..ef056ee717ccb 100644 --- a/apps/meteor/client/components/TwoFactorModal/TwoFactorModal.tsx +++ b/apps/meteor/client/components/TwoFactorModal/TwoFactorModal.tsx @@ -21,7 +21,7 @@ type TwoFactorModalProps = { } | { method: 'email'; - resendEmail?: () => Promise; + emailOrUsername: string; } ); @@ -31,9 +31,9 @@ const TwoFactorModal = ({ onConfirm, onClose, ...props }: TwoFactorModalProps): } if (props.method === Method.EMAIL) { - const { resendEmail } = props; + const { emailOrUsername } = props; - return ; + return ; } if (props.method === Method.PASSWORD) { diff --git a/apps/meteor/client/lib/2fa/process2faReturn.ts b/apps/meteor/client/lib/2fa/process2faReturn.ts index 7bdac41bc1f50..50a0d729d770b 100644 --- a/apps/meteor/client/lib/2fa/process2faReturn.ts +++ b/apps/meteor/client/lib/2fa/process2faReturn.ts @@ -5,7 +5,6 @@ import { lazy } from 'react'; import type { LoginCallback } from './overrideLoginMethod'; import type { MeteorErrorLike } from './types'; import { isTotpInvalidError, isTotpRequiredError } from './utils'; -import { sdk } from '../../../app/utils/client/lib/SDKClient'; import { getUser } from '../user'; const TwoFactorModal = lazy(() => import('../../components/TwoFactorModal')); @@ -32,9 +31,7 @@ const hasRequiredTwoFactorMethod = ( function assertModalProps(props: { method: TwoFactorMethod; emailOrUsername?: string; -}): asserts props is - | { method: 'totp' | 'password'; invalidAttempt?: boolean } - | { method: 'email'; emailOrUsername: string; invalidAttempt?: boolean } { +}): asserts props is { method: 'totp' } | { method: 'password' } | { method: 'email'; emailOrUsername: string } { if (props.method === 'email' && typeof props.emailOrUsername !== 'string') { throw new Error('Invalid Two Factor method'); } @@ -164,10 +161,6 @@ export const invokeTwoFactorModal = async ( reject(new Error('totp-canceled')); } }, - ...(props.method === 'email' && - props.emailOrUsername && { - resendEmail: (): Promise => sdk.rest.post('/v1/users.2fa.sendEmailCode', { emailOrUsername: props.emailOrUsername }), - }), }, }); }); diff --git a/apps/meteor/client/lib/buildAuthDeeplinkURL.ts b/apps/meteor/client/lib/buildAuthDeeplinkURL.ts deleted file mode 100644 index e4481fed271d3..0000000000000 --- a/apps/meteor/client/lib/buildAuthDeeplinkURL.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const buildDeepLinkURL = (resumeToken: string, userId: string) => { - const url = new URL(window.location.href); - const { origin } = url; - return `rocketchat://auth?host=${origin}&token=${resumeToken}&userId=${userId}`; -}; diff --git a/apps/meteor/client/lib/sdk/ddpSdk.ts b/apps/meteor/client/lib/sdk/ddpSdk.ts index 09c2b43ac10a7..35ed4169e29e3 100644 --- a/apps/meteor/client/lib/sdk/ddpSdk.ts +++ b/apps/meteor/client/lib/sdk/ddpSdk.ts @@ -68,7 +68,7 @@ export const getDdpSdk = (): DDPSDK => { return instance; }; -export const readStoredLoginToken = (): string | null => getStoredItem(STORAGE_KEYS.LOGIN_TOKEN); +const readStoredLoginToken = (): string | null => getStoredItem(STORAGE_KEYS.LOGIN_TOKEN); let inflightLogin: Promise | undefined; diff --git a/apps/meteor/client/startup/routes.tsx b/apps/meteor/client/startup/routes.tsx index 2b8e1c43e35e2..77f76158cb65d 100644 --- a/apps/meteor/client/startup/routes.tsx +++ b/apps/meteor/client/startup/routes.tsx @@ -3,7 +3,6 @@ import { createElement, lazy, useEffect } from 'react'; import { appLayout } from '../lib/appLayout'; import { router } from '../providers/RouterProvider'; -import OAuthTwoFactorAuthenticationRouter from '../views/OAuthTwoFactorAuthentication/OAuthTwoFactorAuthenticationRouter'; import MainLayout from '../views/root/MainLayout'; const IndexRoute = lazy(() => import('../views/root/IndexRoute')); @@ -40,10 +39,6 @@ declare module '@rocket.chat/ui-contexts' { pathname: `/meet/${string}`; pattern: '/meet/:rid'; }; - '2fa': { - pathname: `/2fa/${string}/${string}`; - pattern: '/2fa/:method/:challengeId'; - }; 'home': { pathname: '/home'; pattern: '/home'; @@ -136,11 +131,6 @@ router.defineRoutes([ return null; }), }, - { - path: '/2fa/:method/:challengeId', - id: '2fa', - element: appLayout.wrap(), - }, { path: '/home', id: 'home', diff --git a/apps/meteor/client/views/OAuthTwoFactorAuthentication/OAuthTwoFactorAuthenticationRouter.tsx b/apps/meteor/client/views/OAuthTwoFactorAuthentication/OAuthTwoFactorAuthenticationRouter.tsx deleted file mode 100644 index 2394d0a8cfdaf..0000000000000 --- a/apps/meteor/client/views/OAuthTwoFactorAuthentication/OAuthTwoFactorAuthenticationRouter.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { useStableCallback } from '@rocket.chat/fuselage-hooks'; -import { Page } from '@rocket.chat/ui-client'; -import { - useEndpoint, - useLoginWithToken, - useRouteParameter, - useRouter, - useSetModal, - useToastMessageDispatch, -} from '@rocket.chat/ui-contexts'; -import { useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; - -import TwoFactorModal from '../../components/TwoFactorModal/TwoFactorModal'; - -const throwErrorOnInvalidMethod = (method: never): never => { - throw new Error(`Invalid Two Factor method: ${method}`); -}; - -const OAuthTwoFactorAuthenticationRouter = () => { - const method = useRouteParameter('method') as 'totp' | 'email' | undefined; - const challengeId = useRouteParameter('challengeId'); - const router = useRouter(); - const dispatchToastMessage = useToastMessageDispatch(); - const setModal = useSetModal(); - const loginWithToken = useLoginWithToken(); - const { t } = useTranslation(); - const verifyChallenge = useEndpoint('POST', '/v1/twoFactorChallenges.verifyChallenge'); - const sendEmailCode = useEndpoint('POST', '/v1/twoFactorChallenges.sendEmailCode'); - - const navigateToHome = useStableCallback(() => { - setModal(null); - router.navigate('/home', { replace: true }); - }); - - const resendEmail = useStableCallback(async () => { - if (!challengeId) { - return null; - } - await sendEmailCode({ challengeId }); - return null; - }); - - const onConfirm = useStableCallback(async (code: string) => { - if (!challengeId || !code) { - return; - } - try { - const { loginToken, userId } = await verifyChallenge({ challengeId, code }); - - const { loginClient } = router.getSearchParameters(); - - if (loginClient === 'mobile' || loginClient === 'desktop') { - setModal(null); - router.navigate({ name: 'home', search: { resumeToken: loginToken, userId, loginClient } }, { replace: true }); - return; - } - - await loginWithToken(loginToken); - navigateToHome(); - } catch (error: any) { - console.error('Failed to verify challenge', error); - if (error.errorType === 'totp-max-attempts') { - setModal(null); - dispatchToastMessage({ type: 'error', message: t('Maximum_number_of_attempts_reached_please_try_again_later') }); - router.navigate('/login', { replace: true }); - return; - } - if (error.errorType === 'error-challenge-expired' || error.errorType === 'error-challenge-not-found') { - setModal(null); - dispatchToastMessage({ type: 'error', message: t('Challenge_expired_please_try_again_later') }); - router.navigate('/login', { replace: true }); - return; - } - throw error; - } - }); - - useEffect(() => { - if (!method || !challengeId) { - router.navigate('/home'); - return; - } - - if (method === 'email') { - setModal(); - return; - } - - if (method === 'totp') { - setModal(); - return; - } - - throwErrorOnInvalidMethod(method); - }, [method, challengeId, router, setModal, onConfirm, resendEmail, navigateToHome]); - - return ; -}; - -export default OAuthTwoFactorAuthenticationRouter; diff --git a/apps/meteor/client/views/root/AppLayout.tsx b/apps/meteor/client/views/root/AppLayout.tsx index 250690e98d78f..b355d9c7b44f2 100644 --- a/apps/meteor/client/views/root/AppLayout.tsx +++ b/apps/meteor/client/views/root/AppLayout.tsx @@ -27,13 +27,11 @@ import { useKeyboardShortcutsHotkey } from './hooks/useKeyboardShortcutsHotkey'; import { useLivechatEnterprise } from './hooks/useLivechatEnterprise'; import { useLoadMissedMessages } from './hooks/useLoadMissedMessages'; import { useLoadRoomForAllowedAnonymousRead } from './hooks/useLoadRoomForAllowedAnonymousRead'; -import { useLoginOtherClients } from './hooks/useLoginOtherClients'; import { useLoginViaQuery } from './hooks/useLoginViaQuery'; import { useMessageLinkClicks } from './hooks/useMessageLinkClicks'; import { useNotificationPermission } from './hooks/useNotificationPermission'; import { useRedirectToSetupWizard } from './hooks/useRedirectToSetupWizard'; import { useSettingsOnLoadSiteUrl } from './hooks/useSettingsOnLoadSiteUrl'; -import { useShareSessionWithOtherClients } from './hooks/useShareSessionWithOtherClients'; import { useStartupEvent } from './hooks/useStartupEvent'; import { appLayout } from '../../lib/appLayout'; @@ -72,8 +70,6 @@ const AppLayout = () => { useAutoupdate(); useCodeHighlight(); useLoginViaQuery(); - useLoginOtherClients(); - useShareSessionWithOtherClients(); useLoadMissedMessages(); useDesktopFavicon(); useDesktopTitle(); diff --git a/apps/meteor/client/views/root/hooks/useLoginOtherClients.ts b/apps/meteor/client/views/root/hooks/useLoginOtherClients.ts deleted file mode 100644 index a826091886772..0000000000000 --- a/apps/meteor/client/views/root/hooks/useLoginOtherClients.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useRouter, useSearchParameter } from '@rocket.chat/ui-contexts'; -import { useEffect } from 'react'; - -import { buildDeepLinkURL } from '../../../lib/buildAuthDeeplinkURL'; - -export const useLoginOtherClients = () => { - const router = useRouter(); - const resumeToken = useSearchParameter('resumeToken'); - const loginClient = useSearchParameter('loginClient'); - const userId = useSearchParameter('userId'); - - useEffect(() => { - if (!resumeToken || !userId) { - return; - } - - if (loginClient !== 'desktop' && loginClient !== 'mobile') { - return; - } - - const loginURL = buildDeepLinkURL(resumeToken, userId); - window.location.href = loginURL; - - const timeout = setTimeout(() => { - router.navigate('/home', { replace: true }); - }, 0); - - return () => clearTimeout(timeout); - }, [resumeToken, userId, loginClient, router]); -}; diff --git a/apps/meteor/client/views/root/hooks/useLoginViaQuery.ts b/apps/meteor/client/views/root/hooks/useLoginViaQuery.ts index 7bc3b2b93ecb0..a67739eb7b025 100644 --- a/apps/meteor/client/views/root/hooks/useLoginViaQuery.ts +++ b/apps/meteor/client/views/root/hooks/useLoginViaQuery.ts @@ -7,17 +7,12 @@ export const useLoginViaQuery = () => { useEffect(() => { const handleLogin = async () => { - const { resumeToken, loginClient } = router.getSearchParameters(); + const { resumeToken } = router.getSearchParameters(); if (!resumeToken) { return; } - //Case handled by useLoginOtherClients, we don't want to login here. - if (loginClient) { - return; - } - try { await loginWithToken(resumeToken); diff --git a/apps/meteor/client/views/root/hooks/useShareSessionWithOtherClients.ts b/apps/meteor/client/views/root/hooks/useShareSessionWithOtherClients.ts deleted file mode 100644 index 62fd2fee04818..0000000000000 --- a/apps/meteor/client/views/root/hooks/useShareSessionWithOtherClients.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { useRouter, useSearchParameter, useUserId } from '@rocket.chat/ui-contexts'; -import { useEffect } from 'react'; - -import { buildDeepLinkURL } from '../../../lib/buildAuthDeeplinkURL'; -import { readStoredLoginToken } from '../../../lib/sdk/ddpSdk'; - -export const useShareSessionWithOtherClients = () => { - const router = useRouter(); - const userId = useUserId(); - const resumeToken = useSearchParameter('resumeToken'); - const loginClient = useSearchParameter('loginClient'); - - useEffect(() => { - if (!userId) { - return; - } - - const loginToken = readStoredLoginToken(); - - if (!loginToken) { - return; - } - - if (resumeToken) { - return; - } - - if (loginClient !== 'desktop' && loginClient !== 'mobile') { - return; - } - - const loginURL = buildDeepLinkURL(loginToken, userId); - window.location.href = loginURL; - - const timeout = setTimeout(() => { - router.navigate('/home', { replace: true }); - }, 100); - - return () => clearTimeout(timeout); - }, [resumeToken, loginClient, router, userId]); -}; diff --git a/apps/meteor/definition/externals/express-session.d.ts b/apps/meteor/definition/externals/express-session.d.ts deleted file mode 100644 index 6a59655d241a7..0000000000000 --- a/apps/meteor/definition/externals/express-session.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -import 'express-session'; - -declare module 'express-session' { - interface SessionData { - loginClient?: string; - } -} diff --git a/apps/meteor/definition/externals/express.d.ts b/apps/meteor/definition/externals/express.d.ts index 251d8310daf9f..1661385d94f77 100644 --- a/apps/meteor/definition/externals/express.d.ts +++ b/apps/meteor/definition/externals/express.d.ts @@ -9,9 +9,3 @@ declare module 'express' { unauthorized?: boolean; } } -declare global { - namespace Express { - // eslint-disable-next-line @typescript-eslint/no-empty-interface -- merges with Passport's Express.User - interface User extends IUser {} - } -} diff --git a/apps/meteor/definition/externals/meteor/accounts-base.d.ts b/apps/meteor/definition/externals/meteor/accounts-base.d.ts index 9676c1c6d8678..39ea55788e9a7 100644 --- a/apps/meteor/definition/externals/meteor/accounts-base.d.ts +++ b/apps/meteor/definition/externals/meteor/accounts-base.d.ts @@ -23,7 +23,7 @@ declare module 'meteor/accounts-base' { function _generateStampedLoginToken(): { token: string; when: Date }; - function _insertLoginToken(userId: string, token: { token: string; when: Date }): Promise; + function _insertLoginToken(userId: string, token: { token: string; when: Date }): void; function _runLoginHandlers(methodInvocation: T, loginRequest: Record): Promise; @@ -41,9 +41,7 @@ declare module 'meteor/accounts-base' { serviceName: string, serviceData: Record, options: Record, - ): Promise | undefined>; - - function addAutopublishFields(options: Record): void; + ): Record; function _clearAllLoginTokens(userId: string | null): void; diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 4426e80fa582f..3728e84a89348 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -183,7 +183,6 @@ "colorette": "^2.0.20", "colors": "^1.4.0", "connect": "^3.7.0", - "connect-mongo": "5.1.0", "cookie": "^0.7.2", "cookie-parser": "^1.4.7", "cors": "^2.8.6", @@ -206,7 +205,6 @@ "expiry-map": "^2.0.0", "express": "^4.21.2", "express-rate-limit": "^5.5.1", - "express-session": "^1.19.0", "fast-redact": "^3.5.0", "fastq": "^1.17.1", "fflate": "^0.8.2", @@ -263,13 +261,6 @@ "object-path": "^0.11.8", "overlayscrollbars": "~2.11.5", "overlayscrollbars-react": "^0.5.6", - "passport": "^0.7.0", - "passport-apple": "^2.0.2", - "passport-facebook": "^3.0.0", - "passport-github2": "^0.1.12", - "passport-google-oauth20": "^2.0.0", - "passport-oauth2": "^1.8.0", - "passport-twitter": "^1.0.4", "path": "^0.12.7", "path-to-regexp": "^6.3.0", "pino": "10.3.1", @@ -365,7 +356,6 @@ "@types/ejson": "^2.2.2", "@types/express": "^4.17.25", "@types/express-rate-limit": "^5.1.3", - "@types/express-session": "^1", "@types/fast-redact": "^3", "@types/google-libphonenumber": "^7.4.30", "@types/gravatar": "^1.8.6", @@ -396,13 +386,6 @@ "@types/oauth2-server": "^3.0.18", "@types/object-path": "^0.11.4", "@types/parseurl": "^1.3.3", - "@types/passport": "^1.0.17", - "@types/passport-apple": "^2", - "@types/passport-facebook": "^3.0.4", - "@types/passport-github2": "^1.2.9", - "@types/passport-google-oauth20": "^2", - "@types/passport-oauth2": "^1", - "@types/passport-twitter": "^1", "@types/prometheus-gc-stats": "^0.6.4", "@types/proxy-from-env": "^1.0.4", "@types/proxyquire": "^1.3.31", diff --git a/apps/meteor/server/configuration/accounts_meld.js b/apps/meteor/server/configuration/accounts_meld.js index 2c8fff11a820b..037150def37ee 100644 --- a/apps/meteor/server/configuration/accounts_meld.js +++ b/apps/meteor/server/configuration/accounts_meld.js @@ -18,6 +18,10 @@ export async function configureAccounts() { } } + if (serviceName === 'linkedin') { + serviceData.email = serviceData.emailAddress; + } + if (serviceData.email) { const user = await Users.findOneByEmailAddress(serviceData.email); if (user != null && user.services?.[serviceName]?.id !== serviceData.id) { diff --git a/apps/meteor/server/configuration/configurePassport.ts b/apps/meteor/server/configuration/configurePassport.ts deleted file mode 100644 index 46adff48c78de..0000000000000 --- a/apps/meteor/server/configuration/configurePassport.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { Users } from '@rocket.chat/models'; -import bodyParser from 'body-parser'; -import MongoStore from 'connect-mongo'; -import express from 'express'; -import rateLimit from 'express-rate-limit'; -import session from 'express-session'; -import { MongoInternals } from 'meteor/mongo'; -import { WebApp } from 'meteor/webapp'; -import passport from 'passport'; - -import type { ICachedSettings } from '../../app/settings/server/CachedSettings'; -import { configureOAuthServices } from '../lib/oauth/configureOAuthServices'; -import { createOAuthServiceConfig } from '../lib/oauth/createOAuthServiceConfig'; -import { getOAuthServices } from '../lib/oauth/getOAuthServices'; - -const oAuthPaths = ['/oauth', '/_oauth']; - -const { Router: router } = express; - -export const oAuthRouter = router(); - -const oAuthApp = express(); -oAuthApp.set('trust proxy', true); - -export const configurePassport = (settings: ICachedSettings) => { - const { client } = MongoInternals.defaultRemoteCollectionDriver().mongo; - - oAuthApp.use( - oAuthPaths, - session({ - name: 'oauth', - secret: settings.get('Accounts_OAuth_Session_Secret'), - resave: false, - saveUninitialized: false, - proxy: true, - store: MongoStore.create({ - client, - collectionName: 'rocketchat_oauth_sessions', - ttl: 5 * 60, - autoRemove: 'native', - }), - cookie: { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - maxAge: 5 * 60 * 1000, // 5 minutes - sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', - }, - }), - ); - - oAuthApp.use(oAuthPaths, passport.initialize()); - oAuthApp.use(oAuthPaths, passport.session()); - oAuthApp.use(oAuthPaths, bodyParser.urlencoded({ extended: true })); - - const oauthRateLimiter = rateLimit({ - windowMs: settings.get('API_Enable_Rate_Limiter_Limit_Time_Default'), - max: settings.get('API_Enable_Rate_Limiter_Limit_Calls_Default'), - skip: () => - process.env.TEST_MODE === 'true' || - process.env.TEST_MODE === 'api' || - settings.get('API_Enable_Rate_Limiter') !== true || - (process.env.NODE_ENV === 'development' && settings.get('API_Enable_Rate_Limiter_Dev') !== true), - handler: (_req, res) => { - res.status(429).json({ - success: false, - error: 'Too many requests. Please try again later.', - }); - }, - }); - - oAuthRouter.use(oAuthPaths, oauthRateLimiter); - - // Register OAuth Routes - oAuthApp.use(oAuthRouter); - - passport.serializeUser((user: any, done) => { - done(null, user._id); - }); - - passport.deserializeUser(async (id, done) => { - const user = await Users.findOneById(id as string); - // we don’t actually use this user later - done(null, user); - }); - - settings.watchByRegex(/^(Accounts_OAuth_)[a-z0-9_]+$/i, () => { - const services = getOAuthServices(settings); - const oauthServiceConfigs = createOAuthServiceConfig(settings, services); - configureOAuthServices(oauthServiceConfigs, settings); - }); - - WebApp.rawConnectHandlers.use(oAuthApp); -}; diff --git a/apps/meteor/server/configuration/index.ts b/apps/meteor/server/configuration/index.ts index 79f3160120ba1..491c410e26f4a 100644 --- a/apps/meteor/server/configuration/index.ts +++ b/apps/meteor/server/configuration/index.ts @@ -7,7 +7,6 @@ import { configureCORS } from './configureCORS'; import { configureDirectReply } from './configureDirectReply'; import { configureIRC } from './configureIRC'; import { configureLogLevel } from './configureLogLevel'; -import { configurePassport } from './configurePassport'; import { configureSMTP } from './configureSMTP'; import { configureLDAP } from './ldap'; import { configureOAuth } from './oauth'; @@ -29,6 +28,5 @@ export async function configureServer(settings: ICachedSettings) { configureDirectReply(settings), configureSMTP(settings), configureIRC(settings), - configurePassport(settings), ]); } diff --git a/apps/meteor/server/importPackages.ts b/apps/meteor/server/importPackages.ts index 228d809fe6dc2..645ff19932e0a 100644 --- a/apps/meteor/server/importPackages.ts +++ b/apps/meteor/server/importPackages.ts @@ -33,8 +33,6 @@ import '../app/importer-slack-users/server'; import '../app/integrations/server'; import '../app/irc/server'; import '../app/lib/server'; -import '../app/meteor-developer/server'; -import '../app/linkedin/server'; import '../app/token-login/server'; import '../app/mailer/server/api'; import '../app/markdown/server'; diff --git a/apps/meteor/server/lib/oauth/addPassportCustomOAuth.ts b/apps/meteor/server/lib/oauth/addPassportCustomOAuth.ts deleted file mode 100644 index e26f51bdf9c72..0000000000000 --- a/apps/meteor/server/lib/oauth/addPassportCustomOAuth.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { OAuthConfiguration } from '@rocket.chat/core-typings'; -import passport from 'passport'; -import type { DoneCallback, Profile } from 'passport'; - -import { passportOAuthCallback } from './passportOAuthCallback'; -import { verifyFunction } from './verifyFunction'; -import { CustomOAuthStrategy } from '../../../app/custom-oauth/server/customOAuth'; -import { settings } from '../../../app/settings/server'; -import { oAuthRouter } from '../../configuration/configurePassport'; - -export const addPassportCustomOAuth = (serviceName: string, config: Partial) => { - passport.unuse(serviceName); - - if (!config.clientId || !config.clientSecret || !config.serverURL) { - return; - } - - passport.use( - serviceName, - new CustomOAuthStrategy( - serviceName, - config as OAuthConfiguration & { clientSecret: string }, - (accessToken: string, refreshToken: string, profile: Profile, done: DoneCallback) => - verifyFunction(accessToken, refreshToken, profile, done, serviceName), - ), - ); - - const siteUrl = settings.get('Site_Url'); - - oAuthRouter.get( - `/oauth/${serviceName}`, - (req, _res, next) => { - const { loginClient } = req.query; - if (loginClient === 'mobile' || loginClient === 'desktop') { - req.session.loginClient = loginClient; - req.session.save(() => { - next(); - }); - } else { - //delete stale value from previous sessions if any - delete req.session.loginClient; - next(); - } - }, - passport.authenticate(serviceName, { scope: config.scope, prompt: 'consent', failureRedirect: '/login', keepSessionInfo: true }), - ); - - oAuthRouter.get( - `/_oauth/${serviceName}`, - passport.authenticate(serviceName, { failureRedirect: '/login', failureFlash: true, failWithError: true, keepSessionInfo: true }), - passportOAuthCallback(siteUrl), - ); -}; diff --git a/apps/meteor/server/lib/oauth/configureOAuthServices.ts b/apps/meteor/server/lib/oauth/configureOAuthServices.ts deleted file mode 100644 index 5ff0b202a2e89..0000000000000 --- a/apps/meteor/server/lib/oauth/configureOAuthServices.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Users } from '@rocket.chat/models'; -import { Accounts } from 'meteor/accounts-base'; -import passport from 'passport'; -import type { Profile, DoneCallback } from 'passport'; - -import type { OAuthServiceConfig } from './createOAuthServiceConfig'; -import { passportOAuthCallback } from './passportOAuthCallback'; -import type { ICachedSettings } from '../../../app/settings/server/CachedSettings'; -import { oAuthRouter } from '../../configuration/configurePassport'; - -export const configureOAuthServices = (oauthServiceConfig: OAuthServiceConfig[], settings: ICachedSettings) => { - oauthServiceConfig.forEach((config) => { - const Strategy = config.strategy; - const siteUrl = settings.get('Site_Url'); - - passport.unuse(config.provider); - - passport.use( - config.provider, - new Strategy( - { - ...config, - clientID: config.clientId, - clientSecret: config.clientSecret, - consumerKey: config.clientId, - consumerSecret: config.clientSecret, - callbackURL: `${siteUrl}/_oauth/${config.provider}`, - state: true, - pkce: true, - profileFields: ['id', 'displayName', 'emails'], - }, - async (accessToken: string, refreshToken: string, profile: Profile, done: DoneCallback) => { - const profileWithRaw = profile as Profile & { _json?: Record; _raw?: string }; - const { _json, _raw, ...restProfile } = profileWithRaw; - - const user = await Accounts.updateOrCreateUserFromExternalService( - config.provider, - { - accessToken, - refreshToken, - name: profile.displayName, - ...restProfile, - ..._json, - email: profile?.emails?.[0]?.value, - }, - {}, - ); - - if (!user?.userId || typeof user?.userId !== 'string') { - return done(new Error('User not found')); - } - - const userFromDB = await Users.findOneById(user.userId); - - if (!userFromDB) { - return done(new Error('User not found')); - } - - return done(null, userFromDB); - }, - ), - ); - - oAuthRouter.get( - `/oauth/${config.provider}`, - (req, _res, next) => { - const { loginClient } = req.query; - if (loginClient === 'mobile' || loginClient === 'desktop') { - req.session.loginClient = loginClient; - req.session.save(() => { - next(); - }); - } else { - //delete stale value from previous sessions if any - delete req.session.loginClient; - next(); - } - }, - passport.authenticate(config.provider, { scope: config.scope, prompt: 'consent', failureRedirect: '/login', keepSessionInfo: true }), - ); - oAuthRouter.get( - `/_oauth/${config.provider}`, - passport.authenticate(config.provider, { failureRedirect: '/login', failureFlash: true, failWithError: true, keepSessionInfo: true }), - passportOAuthCallback(siteUrl), - ); - }); -}; diff --git a/apps/meteor/server/lib/oauth/createOAuthServiceConfig.ts b/apps/meteor/server/lib/oauth/createOAuthServiceConfig.ts deleted file mode 100644 index 3ea8213140b95..0000000000000 --- a/apps/meteor/server/lib/oauth/createOAuthServiceConfig.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { capitalize } from '@rocket.chat/string-helpers'; -import { isTruthy } from '@rocket.chat/tools'; -import type { Strategy } from 'passport'; - -import { OAuthConfigs } from './oauthConfigs'; -import { type ICachedSettings } from '../../../app/settings/server/CachedSettings'; - -export type OAuthServiceConfig = { - provider: string; - strategy: new (...args: any[]) => Strategy; - clientId: string; - clientSecret: string; - scope?: string[]; -}; - -export const createOAuthServiceConfig = (settings: ICachedSettings, services: string[]): OAuthServiceConfig[] => { - return services - .map((service) => { - if (!OAuthConfigs[service]) { - return; - } - - if (service === 'github_enterprise') { - const clientId = settings.get('Accounts_OAuth_GitHub_Enterprise_id'); - const clientSecret = settings.get('Accounts_OAuth_GitHub_Enterprise_secret'); - const serverUrl = settings.get('API_GitHub_Enterprise_URL'); - - if (!clientId || !clientSecret || !serverUrl) { - return; - } - - return { - provider: service, - clientId, - clientSecret, - authorizationURL: `${serverUrl}/login/oauth/authorize`, - tokenURL: `${serverUrl}/login/oauth/access_token`, - userProfileURL: `${serverUrl}/api/v3/user`, - strategy: OAuthConfigs.github_enterprise.strategy, - scope: OAuthConfigs.github_enterprise.scope, - }; - } - - const clientId = settings.get(`Accounts_OAuth_${capitalize(service)}_id`); - const clientSecret = settings.get(`Accounts_OAuth_${capitalize(service)}_secret`); - - if (!clientId || !clientSecret) { - return; - } - - return { - provider: service, - clientId, - clientSecret, - ...OAuthConfigs[service], - }; - }) - .filter(isTruthy); -}; diff --git a/apps/meteor/server/lib/oauth/getOAuthServices.ts b/apps/meteor/server/lib/oauth/getOAuthServices.ts deleted file mode 100644 index 7a431982d9f71..0000000000000 --- a/apps/meteor/server/lib/oauth/getOAuthServices.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { isTruthy } from '@rocket.chat/tools'; - -import { OAuthConfigs } from './oauthConfigs'; -import { type ICachedSettings } from '../../../app/settings/server/CachedSettings'; - -export const getOAuthServices = (settings: ICachedSettings) => { - const services = settings.getByRegexp(/^(Accounts_OAuth_|Accounts_OAuth_Custom-)[a-z0-9_]+$/i); - const filteredServices = services.filter(([, value]) => typeof value === 'boolean' && value === true); - return filteredServices - .map(([key, value]) => { - if (!value) { - return; - } - - let serviceName = key.replace('Accounts_OAuth_', ''); - if (serviceName === 'Meteor') { - serviceName = 'meteor-developer'; - } - - if (/Accounts_OAuth_Custom-/.test(key)) { - return; - } - - const serviceKey = serviceName.toLowerCase(); - - const oauthConfig = OAuthConfigs[serviceKey]; - if (!oauthConfig) { - return; - } - - return serviceKey; - }) - .filter(isTruthy); -}; diff --git a/apps/meteor/server/lib/oauth/oauthConfigs.ts b/apps/meteor/server/lib/oauth/oauthConfigs.ts deleted file mode 100644 index b4c8fe6604fa5..0000000000000 --- a/apps/meteor/server/lib/oauth/oauthConfigs.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { Strategy } from 'passport'; -import { Strategy as FacebookStrategy } from 'passport-facebook'; -import { Strategy as GitHubStrategy } from 'passport-github2'; -import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; -import { Strategy as TwitterStrategy } from 'passport-twitter'; - -export type OAuthConfig = { - strategy: new (...args: any[]) => Strategy; - scope?: string[]; - includeEmail?: boolean; -}; - -export const OAuthConfigs: Record = { - github: { - strategy: GitHubStrategy, - scope: ['user:email'], - }, - facebook: { - strategy: FacebookStrategy, - scope: ['email'], - }, - google: { - strategy: GoogleStrategy, - scope: ['email', 'profile'], - }, - twitter: { - strategy: TwitterStrategy, - includeEmail: true, - }, - github_enterprise: { - strategy: GitHubStrategy, - scope: ['user:email'], - }, -} as const; - -export type Provider = keyof typeof OAuthConfigs; diff --git a/apps/meteor/server/lib/oauth/passportOAuthCallback.ts b/apps/meteor/server/lib/oauth/passportOAuthCallback.ts deleted file mode 100644 index 7bd128f6ebf7a..0000000000000 --- a/apps/meteor/server/lib/oauth/passportOAuthCallback.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { IUser } from '@rocket.chat/core-typings'; -import { Logger } from '@rocket.chat/logger'; -import type { Request, Response } from 'express'; -import { Accounts } from 'meteor/accounts-base'; - -import { doesUserRequire2FA } from './twoFactorAuth'; - -const logger = new Logger('OAuth'); - -export const passportOAuthCallback = (siteUrl: string) => async (req: Request, res: Response) => { - const oAuthUser = req.user as IUser; - - if (!oAuthUser) { - return res.redirect('/login'); - } - - const { loginClient } = req.session; - - const secondFactorMethod = doesUserRequire2FA(oAuthUser); - - if (secondFactorMethod) { - const challengeId = await secondFactorMethod.sendTwoFactorChallenge(oAuthUser); - const twoFARedirectUrl = new URL(`/2fa/${secondFactorMethod.method}/${challengeId}`, siteUrl); - - if (loginClient) { - twoFARedirectUrl.searchParams.set('loginClient', loginClient); - } - - return res.redirect(twoFARedirectUrl.toString()); - } - - const stampedToken = Accounts._generateStampedLoginToken(); - await Accounts._insertLoginToken(oAuthUser._id, stampedToken); - - const redirectUrl = new URL(`/home`, siteUrl); - - redirectUrl.searchParams.set('resumeToken', stampedToken.token); - redirectUrl.searchParams.set('userId', oAuthUser._id); - - if (loginClient) { - redirectUrl.searchParams.set('loginClient', loginClient); - } - - setImmediate(() => res.redirect(redirectUrl.toString())); - - req.session.destroy((err) => { - if (err) { - logger.error({ msg: 'Error destroying OAuth session', err }); - } - }); -}; diff --git a/apps/meteor/server/lib/oauth/twoFactorAuth.ts b/apps/meteor/server/lib/oauth/twoFactorAuth.ts deleted file mode 100644 index 2c97ff5fc5244..0000000000000 --- a/apps/meteor/server/lib/oauth/twoFactorAuth.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { IUser } from '@rocket.chat/core-typings'; - -import { getRememberDate } from '../../../app/2fa/server/code'; -import { EmailCheckForOAuth } from '../../../app/2fa/server/code/EmailCheckForOAuth'; -import { TOTPCheckForOAuth } from '../../../app/2fa/server/code/TOTPCheckForOAuth'; - -export const emailCheckForOAuth = new EmailCheckForOAuth(); -export const totpCheckForOAuth = new TOTPCheckForOAuth(); - -const twoFACheckMethodsForOAuth = { - [emailCheckForOAuth.method]: emailCheckForOAuth, - [totpCheckForOAuth.method]: totpCheckForOAuth, -}; - -export const getTwoFAMethodForOAuth = (method: 'email' | 'totp') => { - return twoFACheckMethodsForOAuth[method]; -}; - -const getSecondFactorMethod = (user: IUser) => { - return Array.from(Object.values(twoFACheckMethodsForOAuth)).find((method) => method.isEnabled(user)); -}; - -export const doesUserRequire2FA = (user: IUser) => { - const rememberAfterRegistration = getRememberDate(user.createdAt); - - if (rememberAfterRegistration && rememberAfterRegistration > new Date()) { - return false; - } - - const secondFactorMethod = getSecondFactorMethod(user); - - if (!secondFactorMethod) { - return false; - } - - return secondFactorMethod; -}; diff --git a/apps/meteor/server/lib/oauth/updateOAuthServices.ts b/apps/meteor/server/lib/oauth/updateOAuthServices.ts index f501b9c56888a..784628cae44a9 100644 --- a/apps/meteor/server/lib/oauth/updateOAuthServices.ts +++ b/apps/meteor/server/lib/oauth/updateOAuthServices.ts @@ -7,7 +7,6 @@ import type { } from '@rocket.chat/core-typings'; import { LoginServiceConfiguration } from '@rocket.chat/models'; -import { addPassportCustomOAuth } from './addPassportCustomOAuth'; import { logger } from './logger'; import { CustomOAuth } from '../../../app/custom-oauth/server/custom_oauth_server'; import { @@ -69,7 +68,7 @@ export async function updateOAuthServices(): Promise { data.rolesToSync = settings.get(`${key}-roles_to_sync`); data.showButton = settings.get(`${key}-show_button`); - const config = { + new CustomOAuth(serviceKey, { serverURL: data.serverURL, tokenPath: data.tokenPath, identityPath: data.identityPath, @@ -94,12 +93,7 @@ export async function updateOAuthServices(): Promise { rolesToSync: data.rolesToSync, accessTokenParam: data.accessTokenParam, showButton: data.showButton, - clientSecret: data.secret, - clientId: data.clientId, - }; - - new CustomOAuth(serviceKey, config); - addPassportCustomOAuth(serviceKey, config); + }); } if (serviceName === 'Facebook') { (data as FacebookOAuthConfiguration).appId = data.clientId as string; diff --git a/apps/meteor/server/lib/oauth/verifyFunction.ts b/apps/meteor/server/lib/oauth/verifyFunction.ts deleted file mode 100644 index 74d8c051e5992..0000000000000 --- a/apps/meteor/server/lib/oauth/verifyFunction.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Users } from '@rocket.chat/models'; -import { Accounts } from 'meteor/accounts-base'; -import type { DoneCallback, Profile } from 'passport'; - -export const verifyFunction = async ( - accessToken: string, - refreshToken: string, - profile: Profile, - done: DoneCallback, - serviceName: string, -) => { - const profileWithRaw = profile as Profile & { _json?: Record; _raw?: string }; - const { _json, _raw, ...restProfile } = profileWithRaw; - - const user = await Accounts.updateOrCreateUserFromExternalService( - serviceName, - { - accessToken, - refreshToken, - name: profile.displayName, - email: profile?.emails?.[0]?.value, - ...profile, - ...restProfile, - ..._json, - }, - {}, - ); - - if (!user?.userId || typeof user?.userId !== 'string') { - return done(new Error('User not found')); - } - - const userFromDB = await Users.findOneById(user.userId); - - if (!userFromDB) { - return done(new Error('User not found')); - } - - return done(null, userFromDB); -}; diff --git a/apps/meteor/server/models.ts b/apps/meteor/server/models.ts index 1d8481923b987..49b5b98b19f08 100644 --- a/apps/meteor/server/models.ts +++ b/apps/meteor/server/models.ts @@ -75,7 +75,6 @@ import { WebdavAccountsRaw, WorkspaceCredentialsRaw, AbacAttributesRaw, - TwoFactorChallengesRaw, } from '@rocket.chat/models'; import type { Collection } from 'mongodb'; @@ -162,4 +161,3 @@ registerModel('IVideoConferenceModel', new VideoConferenceRaw(db)); registerModel('IWebdavAccountsModel', new WebdavAccountsRaw(db)); registerModel('IWorkspaceCredentialsModel', new WorkspaceCredentialsRaw(db)); registerModel('IAbacAttributesModel', new AbacAttributesRaw(db)); -registerModel('ITwoFactorChallengesModel', new TwoFactorChallengesRaw(db)); diff --git a/apps/meteor/server/settings/oauth.ts b/apps/meteor/server/settings/oauth.ts index 8ee583614882b..2aea9b119cf83 100644 --- a/apps/meteor/server/settings/oauth.ts +++ b/apps/meteor/server/settings/oauth.ts @@ -1,5 +1,3 @@ -import { Random } from '@rocket.chat/random'; - import { settingsRegistry } from '../../app/settings/server'; export const createOauthSettings = () => @@ -407,11 +405,6 @@ export const createOauthSettings = () => enableQuery, }); }); - await this.add('Accounts_OAuth_Session_Secret', Random.secret(), { - type: 'string', - secret: true, - hidden: true, - }); return this.section('Proxy', async function () { await this.add('Accounts_OAuth_Proxy_host', 'https://oauth-proxy.rocket.chat', { type: 'string', diff --git a/apps/meteor/tests/e2e/fixtures/addCustomOAuth.ts b/apps/meteor/tests/e2e/fixtures/addCustomOAuth.ts index 70885f1a634fb..5721266b728d5 100644 --- a/apps/meteor/tests/e2e/fixtures/addCustomOAuth.ts +++ b/apps/meteor/tests/e2e/fixtures/addCustomOAuth.ts @@ -15,7 +15,4 @@ export default async function addCustomOAuth(): Promise { await api.post(`${BASE_API_URL}/settings/Accounts_OAuth_Custom-Test`, { data: { value: false }, headers }); await api.post(`${BASE_API_URL}/settings/Accounts_OAuth_Custom-Test-url`, { data: { value: 'https://rocket.chat' }, headers }); await api.post(`${BASE_API_URL}/settings/Accounts_OAuth_Custom-Test-login_style`, { data: { value: 'redirect' }, headers }); - await api.post(`${BASE_API_URL}/settings/Accounts_OAuth_Custom-Test-id`, { data: { value: 'abc' }, headers }); - await api.post(`${BASE_API_URL}/settings/Accounts_OAuth_Custom-Test-secret`, { data: { value: 'def' }, headers }); - await api.post(`${BASE_API_URL}/settings/Accounts_OAuth_Custom-Test-authorize_path`, { data: { value: '/authorize' }, headers }); } diff --git a/packages/core-typings/src/ILoginServiceConfiguration.ts b/packages/core-typings/src/ILoginServiceConfiguration.ts index c3fb05fa8e18f..76a4779e806e9 100644 --- a/packages/core-typings/src/ILoginServiceConfiguration.ts +++ b/packages/core-typings/src/ILoginServiceConfiguration.ts @@ -34,11 +34,6 @@ export type OAuthConfiguration = { mergeRoles: boolean; rolesToSync: string; showButton: boolean; - addAutopublishFields?: { - forLoggedInUser: string[]; - forOtherUsers: string[]; - }; - pkce?: boolean; }; export type FacebookOAuthConfiguration = Omit, 'clientId'> & { diff --git a/packages/core-typings/src/ITwoFactorChallenge.ts b/packages/core-typings/src/ITwoFactorChallenge.ts deleted file mode 100644 index 783309001ee5d..0000000000000 --- a/packages/core-typings/src/ITwoFactorChallenge.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface ITwoFactorChallenge { - _id: string; - userId: string; - method: 'email' | 'totp'; - expireAt: Date; - createdAt: Date; -} diff --git a/packages/core-typings/src/IUser.ts b/packages/core-typings/src/IUser.ts index bd240e0cdc7bb..857d4a07fd91b 100644 --- a/packages/core-typings/src/IUser.ts +++ b/packages/core-typings/src/IUser.ts @@ -233,7 +233,7 @@ export interface IUser extends IRocketChatRecord { isOAuthUser?: boolean; // client only field __rooms?: string[]; inactiveReason?: 'deactivated' | 'pending_approval' | 'idle_too_long'; - providerId?: string; + abacAttributes?: IAbacAttributeDefinition[]; } diff --git a/packages/core-typings/src/index.ts b/packages/core-typings/src/index.ts index 0b4cd9a471675..90dd9c4e638a8 100644 --- a/packages/core-typings/src/index.ts +++ b/packages/core-typings/src/index.ts @@ -134,5 +134,3 @@ export type * from './ServerAudit/IAuditServerAbacAction'; export type * from './ServerAudit/IAuditUserChangedEvent'; export { schemas } from './Ajv'; - -export type * from './ITwoFactorChallenge'; diff --git a/packages/desktop-api/src/index.ts b/packages/desktop-api/src/index.ts index 5344ad5d18005..9d10c1f89d1c5 100644 --- a/packages/desktop-api/src/index.ts +++ b/packages/desktop-api/src/index.ts @@ -63,5 +63,4 @@ export interface IRocketChatDesktop { setUserToken: (token: string, userId: string) => void; openDocumentViewer: (url: string, format: string, options: any) => void; reloadServer: () => void; - openInBrowser: (url: string) => void; } diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 99f7b4b067fb8..3d361dc9ac675 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -1080,7 +1080,6 @@ "Categories": "Categories", "Categories*": "Categories*", "Certificates_and_Keys": "Certificates and Keys", - "Challenge_expired_please_try_again_later": "Challenge expired. Please try again later.", "Change_Room_Type": "Changing the Room Type", "Changed_from": "Changed from", "Changed_to": "Changed to", @@ -3413,7 +3412,6 @@ "Max_number_of_chats_per_agent_description": "The max. number of simultaneous chats that the agents can attend", "Max_number_of_uses": "Max number of uses", "Maximum": "Maximum", - "Maximum_number_of_attempts_reached_please_try_again_later": "Maximum number of attempts reached. Please try again later.", "Maximum_number_of_guests_reached": "Maximum number of guests reached", "Me": "Me", "Media": "Media", @@ -6838,7 +6836,6 @@ "registration.component.form.usernameAlreadyInUse": "Username already in use", "registration.component.form.usernameContainsInvalidChars": "Username contains invalid characters", "registration.component.login": "Login", - "registration.component.login.onWeb": "Login on web", "registration.component.login.incorrectPassword": "Incorrect password", "registration.component.login.userNotFound": "User not found", "registration.component.resetPassword": "Reset password", diff --git a/packages/model-typings/src/index.ts b/packages/model-typings/src/index.ts index 7e459803350b9..439447c49ea02 100644 --- a/packages/model-typings/src/index.ts +++ b/packages/model-typings/src/index.ts @@ -81,4 +81,3 @@ export type * from './updater'; export type * from './models/IWorkspaceCredentialsModel'; export type * from './models/ICallHistoryModel'; export type * from './models/IAbacAttributesModel'; -export type * from './models/ITwoFactorChallengesModel'; diff --git a/packages/model-typings/src/models/ITwoFactorChallengesModel.ts b/packages/model-typings/src/models/ITwoFactorChallengesModel.ts deleted file mode 100644 index e0f523ae49104..0000000000000 --- a/packages/model-typings/src/models/ITwoFactorChallengesModel.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { ITwoFactorChallenge } from '@rocket.chat/core-typings'; -import type { DeleteResult, FindOptions } from 'mongodb'; - -import type { IBaseModel } from './IBaseModel'; - -export interface ITwoFactorChallengesModel extends IBaseModel { - findOneByPendingChallengeId(id: string, options?: FindOptions): Promise; - removeByPendingChallengeId(id: string): Promise; - createTwoFactorChallenge(userId: string, method: ITwoFactorChallenge['method']): Promise; -} diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 753e851d67a62..099420cd2f7ae 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -80,7 +80,6 @@ import type { IMediaCallNegotiationsModel, ICallHistoryModel, IAbacAttributesModel, - ITwoFactorChallengesModel, } from '@rocket.chat/model-typings'; import type { Collection, Db } from 'mongodb'; @@ -208,7 +207,6 @@ export const Migrations = proxify('IMigrationsModel'); export const ModerationReports = proxify('IModerationReportsModel'); export const WorkspaceCredentials = proxify('IWorkspaceCredentialsModel'); export const AbacAttributes = proxify('IAbacAttributesModel'); -export const TwoFactorChallenges = proxify('ITwoFactorChallengesModel'); export function registerServiceModels(db: Db, trash?: Collection>): void { registerModel('IUsersSessionsModel', () => new UsersSessionsRaw(db)); diff --git a/packages/models/src/modelClasses.ts b/packages/models/src/modelClasses.ts index b9d446e64c1ac..c554fc9698550 100644 --- a/packages/models/src/modelClasses.ts +++ b/packages/models/src/modelClasses.ts @@ -73,4 +73,3 @@ export * from './models/MediaCallNegotiations'; export * from './models/WorkspaceCredentials'; export * from './models/Trash'; export * from './models/CallHistory'; -export * from './models/TwoFactorChallenges'; diff --git a/packages/models/src/models/TwoFactorChallenges.ts b/packages/models/src/models/TwoFactorChallenges.ts deleted file mode 100644 index 2d4fb748c1d15..0000000000000 --- a/packages/models/src/models/TwoFactorChallenges.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { randomBytes } from 'crypto'; - -import type { ITwoFactorChallenge } from '@rocket.chat/core-typings'; -import type { ITwoFactorChallengesModel } from '@rocket.chat/model-typings'; -import type { Db, FindOptions, IndexDescription } from 'mongodb'; - -import { BaseRaw } from './BaseRaw'; - -export class TwoFactorChallengesRaw extends BaseRaw implements ITwoFactorChallengesModel { - constructor(db: Db) { - super(db, 'two_factor_challenges'); - } - - override modelIndexes(): IndexDescription[] { - return [{ key: { expireAt: 1 }, expireAfterSeconds: 0 }]; - } - - findOneByPendingChallengeId(pendingChallengeId: string, options?: FindOptions) { - return this.findOne({ _id: pendingChallengeId }, options); - } - - removeByPendingChallengeId(pendingChallengeId: string) { - return this.deleteOne({ _id: pendingChallengeId }); - } - - async createTwoFactorChallenge(userId: string, method: ITwoFactorChallenge['method']): Promise { - const now = new Date(); - const challengeId = randomBytes(32).toString('hex'); - await this.insertOne({ - _id: challengeId, - userId, - method, - createdAt: now, - expireAt: new Date(now.getTime() + 1000 * 60 * 5), - }); - return challengeId; - } -} diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts index ef1f94da30eb1..9c25e22e983f8 100644 --- a/packages/rest-typings/src/index.ts +++ b/packages/rest-typings/src/index.ts @@ -41,7 +41,6 @@ import type { SettingsEndpoints } from './v1/settings'; import type { StatisticsEndpoints } from './v1/statistics'; import type { SubscriptionsEndpoints } from './v1/subscriptionsEndpoints'; import type { TeamsEndpoints } from './v1/teams'; -import type { TwoFactorChallengesEndpoints } from './v1/twoFactorChallenges'; import type { UsersEndpoints } from './v1/users'; import type { VideoConferenceEndpoints } from './v1/videoConference'; @@ -91,7 +90,6 @@ export interface Endpoints AuthEndpoints, ImportEndpoints, ServerEventsEndpoints, - TwoFactorChallengesEndpoints, DefaultEndpoints {} type OperationsByPathPatternAndMethod< @@ -266,7 +264,6 @@ export * from './v1/auth'; export * from './v1/cloud'; export * from './v1/banners'; export * from './default'; -export * from './v1/twoFactorChallenges'; // Export the ajv instance for use in other packages export * from './v1/Ajv'; diff --git a/packages/rest-typings/src/v1/twoFactorChallenges.ts b/packages/rest-typings/src/v1/twoFactorChallenges.ts deleted file mode 100644 index 42b471b244a48..0000000000000 --- a/packages/rest-typings/src/v1/twoFactorChallenges.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { ajv } from './Ajv'; - -type TwoFactorChallengesSendEmailCode = { challengeId: string }; - -const TwoFactorChallengesSendEmailCodeSchema = { - type: 'object', - properties: { - challengeId: { - type: 'string', - }, - }, - required: ['challengeId'], - additionalProperties: false, -}; - -export const isTwoFactorChallengesSendEmailCodeParamsPOST = ajv.compile( - TwoFactorChallengesSendEmailCodeSchema, -); - -type TwoFactorChallengesVerifyChallenge = { challengeId: string; code: string }; - -const TwoFactorChallengesVerifyChallengeSchema = { - type: 'object', - properties: { - challengeId: { type: 'string' }, - code: { type: 'string' }, - }, - required: ['challengeId', 'code'], - additionalProperties: false, -}; - -export const isTwoFactorChallengesVerifyChallengeParamsPOST = ajv.compile( - TwoFactorChallengesVerifyChallengeSchema, -); - -export type TwoFactorChallengesEndpoints = { - '/v1/twoFactorChallenges.sendEmailCode': { - POST: (params: TwoFactorChallengesSendEmailCode) => void; - }; - '/v1/twoFactorChallenges.verifyChallenge': { - POST: (params: TwoFactorChallengesVerifyChallenge) => { loginToken: string; userId: string }; - }; -}; diff --git a/packages/web-ui-registration/global.d.ts b/packages/web-ui-registration/global.d.ts deleted file mode 100644 index 51c30e263ccad..0000000000000 --- a/packages/web-ui-registration/global.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { IRocketChatDesktop } from '@rocket.chat/desktop-api'; - -declare global { - interface Window { - RocketChatDesktop?: IRocketChatDesktop; - } -} diff --git a/packages/web-ui-registration/src/LoginServices.tsx b/packages/web-ui-registration/src/LoginServices.tsx index df490dfcd4d59..e36c032e0436f 100644 --- a/packages/web-ui-registration/src/LoginServices.tsx +++ b/packages/web-ui-registration/src/LoginServices.tsx @@ -1,13 +1,11 @@ -import { Button, ButtonGroup, Divider } from '@rocket.chat/fuselage'; +import { ButtonGroup, Divider } from '@rocket.chat/fuselage'; import { useLoginServices, useSetting } from '@rocket.chat/ui-contexts'; -import { useMemo, type Dispatch, type ReactElement, type SetStateAction } from 'react'; +import type { Dispatch, ReactElement, SetStateAction } from 'react'; import { useTranslation } from 'react-i18next'; import type { LoginErrorState } from './LoginForm'; import LoginServicesButton from './LoginServicesButton'; -const servicesToBeShownOnDesktop = ['saml', 'cas', 'ldap']; - const LoginServices = ({ disabled, setError, @@ -19,28 +17,10 @@ const LoginServices = ({ const services = useLoginServices(); const showFormLogin = useSetting('Accounts_ShowFormLogin'); - const isDesktopApp = !!window.RocketChatDesktop?.openInBrowser; - - const servicesToShow = useMemo( - () => (isDesktopApp ? services.filter(({ service }) => servicesToBeShownOnDesktop.includes(service)) : services), - [isDesktopApp, services], - ); - if (services.length === 0) { return null; } - const handleLoginOnWeb = () => { - if (!isDesktopApp) { - return; - } - - const redirectUrl = new URL(window.location.href); - redirectUrl.searchParams.set('loginClient', 'desktop'); - - window.RocketChatDesktop?.openInBrowser(redirectUrl.toString()); - }; - return ( <> {showFormLogin && ( @@ -48,20 +28,11 @@ const LoginServices = ({ {t('registration.component.form.divider')} )} - - {servicesToShow.length > 0 && ( - - {servicesToShow.map((service) => ( - - ))} - - )} - - {isDesktopApp && ( - - )} + + {services.map((service) => ( + + ))} + ); }; diff --git a/packages/web-ui-registration/src/LoginServicesButton.tsx b/packages/web-ui-registration/src/LoginServicesButton.tsx index 62d3d9856cd17..7eb22ad5dbfc6 100644 --- a/packages/web-ui-registration/src/LoginServicesButton.tsx +++ b/packages/web-ui-registration/src/LoginServicesButton.tsx @@ -8,8 +8,6 @@ import { useTranslation } from 'react-i18next'; import type { LoginErrorState, LoginErrors } from './LoginForm'; -const servicesSupportedByMeteor = ['saml', 'cas', 'ldap']; - const LoginServicesButton = ({ buttonLabelText, icon, @@ -30,28 +28,13 @@ const LoginServicesButton = ({ const handler = useLoginWithService({ service, buttonLabelText, ...props }); const handleOnClick = useCallback(() => { - if (!servicesSupportedByMeteor.includes(service)) { - const url = new URL(window.location.href); - const queryParams = url.searchParams; - const loginClient = queryParams.get('loginClient'); - - const redirectUrl = new URL(`/oauth/${service}`, window.location.origin); - - if (loginClient) { - redirectUrl.searchParams.set('loginClient', loginClient); - } - - window.location.href = redirectUrl.toString(); - return; - } - handler().catch((e: { error?: LoginErrors; reason?: string }) => { if (!e.error || typeof e.error !== 'string') { return; } setError?.([e.error, e.reason]); }); - }, [handler, setError, service]); + }, [handler, setError]); return ( - diff --git a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.tsx b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.tsx index f1f3a7c6a75e5..66f332b82fe7c 100644 --- a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.tsx +++ b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.tsx @@ -12,9 +12,17 @@ type ABACAttributeAutocompleteProps = { index: number; attributeList: { value: string; label: string; attributeValues: string[] }[]; required?: boolean; + disabled?: boolean; }; -const RoomFormAttributeField = ({ labelId, onRemove, index, attributeList, required = false }: ABACAttributeAutocompleteProps) => { +const RoomFormAttributeField = ({ + labelId, + onRemove, + index, + attributeList, + required = false, + disabled = false, +}: ABACAttributeAutocompleteProps) => { const { t } = useTranslation(); const { control, getValues, resetField } = useFormContext(); @@ -71,6 +79,7 @@ const RoomFormAttributeField = ({ labelId, onRemove, index, attributeList, requi mbe={4} error={keyFieldState.error?.message} withTruncatedText + disabled={disabled} onChange={(value) => { keyField.onChange(value); resetField(`attributes.${index}.values`, { defaultValue: [] }); @@ -94,6 +103,7 @@ const RoomFormAttributeField = ({ labelId, onRemove, index, attributeList, requi options={valueOptions} placeholder={t('ABAC_Select_Attribute_Values')} error={valuesFieldState.error?.message} + disabled={disabled} /> {valuesFieldState.error && ( @@ -102,7 +112,7 @@ const RoomFormAttributeField = ({ labelId, onRemove, index, attributeList, requi )} {index !== 0 && ( - )} diff --git a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeFields.spec.tsx b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeFields.spec.tsx index 5f585e3099ed7..8672d8c7eb826 100644 --- a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeFields.spec.tsx +++ b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeFields.spec.tsx @@ -43,7 +43,9 @@ const appRoot = mockAppRoot() ABAC_Search_Attribute: 'Search attribute', ABAC_Select_Attribute_Values: 'Select attribute values', Remove: 'Remove', + ABAC_Picker_External_Store_Helper: 'Available attributes are limited to those you possess.', }) + .withSetting('ABAC_Attribute_Store', 'local') .build(); const FormProviderWrapper = ({ children, defaultValues }: { children: ReactNode; defaultValues?: Partial }) => { @@ -133,4 +135,37 @@ describe('RoomFormAttributeFields', () => { expect(screen.getByText('Public')).toBeInTheDocument(); expect(screen.getByText('Internal')).toBeInTheDocument(); }); + + it('should render fields with disabled inputs when disabled prop is true', () => { + const fields = [{ id: 'field-1' }, { id: 'field-2' }]; + + render( + + + , + { wrapper: appRoot }, + ); + + const attributeLabels = screen.getAllByText('Attribute'); + expect(attributeLabels).toHaveLength(2); + + screen.getAllByPlaceholderText('Search attribute').forEach((input) => expect(input).toBeDisabled()); + screen.getAllByRole('combobox').forEach((input) => expect(input).toBeDisabled()); + expect(screen.getByRole('button', { name: 'Remove' })).toBeDisabled(); + }); + + it('should render fields without disabled state when disabled prop is false', () => { + const fields = [{ id: 'field-1' }]; + + render( + + + , + { wrapper: appRoot }, + ); + + expect(screen.getAllByText('Attribute')).toHaveLength(1); + screen.getAllByPlaceholderText('Search attribute').forEach((input) => expect(input).not.toBeDisabled()); + screen.getAllByRole('combobox').forEach((input) => expect(input).not.toBeDisabled()); + }); }); diff --git a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeFields.tsx b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeFields.tsx index 49555685329a8..01077968233a9 100644 --- a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeFields.tsx +++ b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeFields.tsx @@ -1,16 +1,19 @@ -import { Field, FieldLabel, InputBoxSkeleton } from '@rocket.chat/fuselage'; +import { Box, Field, FieldLabel, InputBoxSkeleton } from '@rocket.chat/fuselage'; import { useTranslation } from 'react-i18next'; import RoomFormAttributeField from './RoomFormAttributeField'; import { useAttributeList } from '../hooks/useAttributeList'; +import { useIsExternalAttributeStore } from '../hooks/useIsExternalAttributeStore'; type RoomFormAttributeFieldsProps = { fields: { id: string }[]; remove: (index: number) => void; + disabled?: boolean; }; -const RoomFormAttributeFields = ({ fields, remove }: RoomFormAttributeFieldsProps) => { +const RoomFormAttributeFields = ({ fields, remove, disabled = false }: RoomFormAttributeFieldsProps) => { const { t } = useTranslation(); + const isExternalAttributeStore = useIsExternalAttributeStore(); const { data: attributeList, isLoading } = useAttributeList(); @@ -18,22 +21,32 @@ const RoomFormAttributeFields = ({ fields, remove }: RoomFormAttributeFieldsProp return ; } - return fields.map((field, index) => ( - - - {t('Attribute')} - - { - remove(index); - }} - index={index} - /> - - )); + return ( + <> + {isExternalAttributeStore && ( + + {t('ABAC_Picker_External_Store_Helper')} + + )} + {fields.map((field, index) => ( + + + {t('Attribute')} + + { + remove(index); + }} + index={index} + disabled={disabled} + /> + + ))} + + ); }; export default RoomFormAttributeFields; diff --git a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAutocomplete.tsx b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAutocomplete.tsx index 506c2e3a1f8e1..6d36acd6e2b00 100644 --- a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAutocomplete.tsx +++ b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAutocomplete.tsx @@ -28,7 +28,7 @@ const RoomFormAutocomplete = ({ value, onSelectedRoom, ...props }: RoomFormAutoc placeholderData: keepPreviousData, select: (data) => data.rooms - .filter((room) => !room.abacAttributes || room.abacAttributes.length === 0) + .filter((room) => !room.abacAttributesRedacted && (!room.abacAttributes || room.abacAttributes.length === 0)) .map((room) => ({ value: room._id, label: { name: room.fname || room.name }, diff --git a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomsContextualBar.tsx b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomsContextualBar.tsx index 25c9037c86e67..fce115b2cbfdf 100644 --- a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomsContextualBar.tsx +++ b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomsContextualBar.tsx @@ -13,11 +13,12 @@ type RoomsContextualBarProps = { attributeId?: string; roomInfo?: { rid: string; name: string }; attributesData?: { key: string; values: string[] }[]; + redacted?: boolean; onClose: () => void; }; -const RoomsContextualBar = ({ roomInfo, attributesData, onClose }: RoomsContextualBarProps) => { +const RoomsContextualBar = ({ roomInfo, attributesData, redacted = false, onClose }: RoomsContextualBarProps) => { const { t } = useTranslation(); const queryClient = useQueryClient(); @@ -80,6 +81,7 @@ const RoomsContextualBar = ({ roomInfo, attributesData, onClose }: RoomsContextu onSave={(values) => saveMutation.mutateAsync(values)} onClose={onClose} setSelectedRoomLabel={setSelectedRoomLabel} + redacted={redacted} /> diff --git a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomsContextualBarWithData.tsx b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomsContextualBarWithData.tsx index e0c22404108b9..2aaf7142865b8 100644 --- a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomsContextualBarWithData.tsx +++ b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomsContextualBarWithData.tsx @@ -22,10 +22,13 @@ const RoomsContextualBarWithData = ({ id, onClose }: RoomsContextualBarWithDataP return ; } + const redacted = data?.abacAttributesRedacted === true; + return ( ); diff --git a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomsPage.tsx b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomsPage.tsx index 3dd724052942a..dc627292ca31f 100644 --- a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomsPage.tsx +++ b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomsPage.tsx @@ -19,6 +19,7 @@ import GenericNoResults from '../../../../components/GenericNoResults'; import { RoomIcon } from '../../../../components/RoomIcon'; import { ABACQueryKeys } from '../../../../lib/queryKeys'; import { useIsABACAvailable } from '../hooks/useIsABACAvailable'; +import { useIsExternalAttributeStore } from '../hooks/useIsExternalAttributeStore'; const RoomsPage = () => { const { t } = useTranslation(); @@ -33,6 +34,7 @@ const RoomsPage = () => { const { current, itemsPerPage, setItemsPerPage, setCurrent, ...paginationProps } = usePagination(); const getRooms = useEndpoint('GET', '/v1/abac/rooms'); const isABACAvailable = useIsABACAvailable(); + const isExternalStore = useIsExternalAttributeStore(); const handleNewAttribute = useEffectEvent(() => { router.navigate({ @@ -62,6 +64,7 @@ const RoomsPage = () => { const { data, isLoading } = useQuery({ queryKey: ABACQueryKeys.rooms.list(query), queryFn: () => getRooms(query), + ...(isExternalStore && { staleTime: 0, gcTime: 0 }), }); return ( @@ -116,10 +119,14 @@ const RoomsPage = () => { {room.usersCount} - {room.abacAttributes?.flatMap((attribute) => attribute.key ?? []).join(', ')} + {room.abacAttributesRedacted + ? t('ABAC_Redacted_Placeholder') + : room.abacAttributes?.flatMap((attribute) => attribute.key ?? []).join(', ')} - {room.abacAttributes?.flatMap((attribute) => attribute.values ?? []).join(', ')} + {room.abacAttributesRedacted + ? t('ABAC_Redacted_Placeholder') + : room.abacAttributes?.flatMap((attribute) => attribute.values ?? []).join(', ')} diff --git a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx index 62771f8a3a9dd..0d37794caefee 100644 --- a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx +++ b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx @@ -18,6 +18,7 @@ const SettingsPage = () => { + {pdpType !== 'local' && } diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACRoute.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACRoute.tsx index 6aedc09a92a7a..79b025b679fe2 100644 --- a/apps/meteor/client/views/admin/ABAC/AdminABACRoute.tsx +++ b/apps/meteor/client/views/admin/ABAC/AdminABACRoute.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import AdminABACPage from './AdminABACPage'; import type { ABACTab } from './hooks/useABACTabPermissions'; import { ABAC_TAB_ORDER, useABACTabPermissions } from './hooks/useABACTabPermissions'; +import { useIsExternalAttributeStore } from './hooks/useIsExternalAttributeStore'; import ABACUpsellModal from '../../../components/ABAC/ABACUpsellModal/ABACUpsellModal'; import { useUpsellActions } from '../../../components/GenericUpsellModal/hooks'; import PageSkeleton from '../../../components/PageSkeleton'; @@ -22,8 +23,12 @@ const AdminABACRoute = (): ReactElement => { const tab = useRouteParameter('tab'); const router = useRouter(); const tabPermissions = useABACTabPermissions(); - const firstAllowedTab = ABAC_TAB_ORDER.find((t) => tabPermissions[t]); - const isAllowedTab = (ABAC_TAB_ORDER as readonly string[]).includes(tab ?? '') && tabPermissions[tab as ABACTab]; + const isExternalStore = useIsExternalAttributeStore(); + const firstAllowedTab = ABAC_TAB_ORDER.find((t) => tabPermissions[t] && !(t === 'room-attributes' && isExternalStore)); + const isAllowedTab = + (ABAC_TAB_ORDER as readonly string[]).includes(tab ?? '') && + tabPermissions[tab as ABACTab] && + !(tab === 'room-attributes' && isExternalStore); const ABACEnabledSetting = useSettingStructure('ABAC_Enabled'); diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACTabs.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACTabs.tsx index c4d77e92d0492..579d8c5d44801 100644 --- a/apps/meteor/client/views/admin/ABAC/AdminABACTabs.tsx +++ b/apps/meteor/client/views/admin/ABAC/AdminABACTabs.tsx @@ -3,12 +3,14 @@ import { useRouteParameter, useRouter } from '@rocket.chat/ui-contexts'; import { useTranslation } from 'react-i18next'; import { useABACTabPermissions } from './hooks/useABACTabPermissions'; +import { useIsExternalAttributeStore } from './hooks/useIsExternalAttributeStore'; const AdminABACTabs = () => { const { t } = useTranslation(); const router = useRouter(); const tab = useRouteParameter('tab'); const tabPermissions = useABACTabPermissions(); + const isExternalStore = useIsExternalAttributeStore(); const handleTabClick = (tab: string) => { router.navigate({ name: 'admin-ABAC', @@ -22,7 +24,7 @@ const AdminABACTabs = () => { {t('Settings')} )} - {tabPermissions['room-attributes'] && ( + {tabPermissions['room-attributes'] && !isExternalStore && ( handleTabClick('room-attributes')}> {t('ABAC_Room_Attributes')} diff --git a/apps/meteor/client/views/admin/ABAC/hooks/useAttributeList.ts b/apps/meteor/client/views/admin/ABAC/hooks/useAttributeList.ts index d17fac26ea532..c4b26d865cbe5 100644 --- a/apps/meteor/client/views/admin/ABAC/hooks/useAttributeList.ts +++ b/apps/meteor/client/views/admin/ABAC/hooks/useAttributeList.ts @@ -5,6 +5,7 @@ import { useIsABACAvailable } from './useIsABACAvailable'; import { ABACQueryKeys } from '../../../../lib/queryKeys'; const COUNT = 150; +const ATTRIBUTE_LIST_STALE_TIME = 15_000; export const useAttributeList = () => { const attributesAutoCompleteEndpoint = useEndpoint('GET', '/v1/abac/attributes'); @@ -12,6 +13,7 @@ export const useAttributeList = () => { return useQuery({ enabled: isABACAvailable, + staleTime: ATTRIBUTE_LIST_STALE_TIME, queryKey: ABACQueryKeys.roomAttributes.list(), queryFn: async () => { const firstPage = await attributesAutoCompleteEndpoint({ offset: 0, count: COUNT }); diff --git a/apps/meteor/client/views/admin/ABAC/hooks/useIsExternalAttributeStore.ts b/apps/meteor/client/views/admin/ABAC/hooks/useIsExternalAttributeStore.ts new file mode 100644 index 0000000000000..5e3e4ce590672 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/hooks/useIsExternalAttributeStore.ts @@ -0,0 +1,8 @@ +import { useSetting } from '@rocket.chat/ui-contexts'; + +export const useIsExternalAttributeStore = (): boolean => { + const pdpType = useSetting('ABAC_PDP_Type', 'local'); + const attributeStore = useSetting('ABAC_Attribute_Store', 'local'); + + return pdpType !== 'local' && attributeStore !== 'local'; +}; diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index bee8f7061c795..39b811c4adb10 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -1,4 +1,4 @@ -import { getPdpHealthErrorCode } from '@rocket.chat/abac'; +import { AbacAttributeStoreExternalError, getPdpHealthErrorCode } from '@rocket.chat/abac'; import { Abac } from '@rocket.chat/core-services'; import type { AbacActor } from '@rocket.chat/core-services'; import type { IServerEvents, IUser } from '@rocket.chat/core-typings'; @@ -41,6 +41,12 @@ const getActorFromUser = (user?: IUser | null): AbacActor | undefined => } : undefined; +const assertLocalAttributeStore = async (): Promise => { + if (await Abac.isExternalAttributeStore()) { + throw new AbacAttributeStoreExternalError(); + } +}; + const abacEndpoints = API.v1 .post( 'abac/rooms/:rid/attributes', @@ -244,6 +250,8 @@ const abacEndpoints = API.v1 throw new Error('error-abac-not-enabled'); } + await assertLocalAttributeStore(); + await Abac.addAbacAttribute(this.bodyParams, getActorFromUser(this.user)); return API.v1.success(); }, @@ -269,6 +277,8 @@ const abacEndpoints = API.v1 throw new Error('error-abac-not-enabled'); } + await assertLocalAttributeStore(); + await Abac.updateAbacAttributeById(_id, this.bodyParams, getActorFromUser(this.user)); return API.v1.success(); }, @@ -288,6 +298,9 @@ const abacEndpoints = API.v1 }, async function action() { const { _id } = this.urlParams; + + await assertLocalAttributeStore(); + const result = await Abac.getAbacAttributeById(_id, getActorFromUser(this.user)); return API.v1.success(result); }, @@ -307,6 +320,9 @@ const abacEndpoints = API.v1 }, async function action() { const { _id } = this.urlParams; + + await assertLocalAttributeStore(); + await Abac.deleteAbacAttributeById(_id, getActorFromUser(this.user)); return API.v1.success(); }, @@ -326,6 +342,9 @@ const abacEndpoints = API.v1 }, async function action() { const { key } = this.urlParams; + + await assertLocalAttributeStore(); + const inUse = await Abac.isAbacAttributeInUseByKey(key); return API.v1.success({ inUse }); }, @@ -414,7 +433,13 @@ const abacEndpoints = API.v1 $lte: end ? new Date(end) : new Date(), }, t: { - $in: ['abac.attribute.changed', 'abac.object.attribute.changed', 'abac.object.attributes.removed', 'abac.action.performed'], + $in: [ + 'abac.attribute.changed', + 'abac.object.attribute.changed', + 'abac.object.attributes.removed', + 'abac.action.performed', + 'abac.attribute.store.switched', + ], }, }, { @@ -433,6 +458,7 @@ const abacEndpoints = API.v1 | IServerEvents['abac.attribute.changed'] | IServerEvents['abac.object.attribute.changed'] | IServerEvents['abac.object.attributes.removed'] + | IServerEvents['abac.attribute.store.switched'] )[], count: events.length, offset, diff --git a/apps/meteor/ee/server/api/abac/schemas.ts b/apps/meteor/ee/server/api/abac/schemas.ts index 582b43291d1f3..34c46e36b9591 100644 --- a/apps/meteor/ee/server/api/abac/schemas.ts +++ b/apps/meteor/ee/server/api/abac/schemas.ts @@ -1,4 +1,11 @@ -import type { IAbacAttribute, IAbacAttributeDefinition, IAuditServerActor, IRoom, IServerEvents } from '@rocket.chat/core-typings'; +import type { + IAbacAttribute, + IAbacAttributeDefinition, + IAuditServerActor, + IRoom, + IRoomAbacRedaction, + IServerEvents, +} from '@rocket.chat/core-typings'; import type { PaginatedResult, PaginatedRequest } from '@rocket.chat/rest-typings'; import { ajv, ajvQuery } from '@rocket.chat/rest-typings'; @@ -223,6 +230,7 @@ export const GETAbacAuditEventsResponseSchema = ajv.compile<{ | IServerEvents['abac.attribute.changed'] | IServerEvents['abac.object.attribute.changed'] | IServerEvents['abac.object.attributes.removed'] + | IServerEvents['abac.attribute.store.switched'] )[]; count: number; offset: number; @@ -408,7 +416,7 @@ export const GETAbacRoomsResponseSchema = { }; type GETAbacRoomsResponse = PaginatedResult<{ - rooms: IRoom[]; + rooms: Array; }>; export const GETAbacRoomsResponseValidator = ajv.compile(GETAbacRoomsResponseSchema); diff --git a/apps/meteor/ee/server/hooks/abac/index.ts b/apps/meteor/ee/server/hooks/abac/index.ts index 8f93423047ad8..b0634916af5cd 100644 --- a/apps/meteor/ee/server/hooks/abac/index.ts +++ b/apps/meteor/ee/server/hooks/abac/index.ts @@ -1 +1,2 @@ import './beforeAddUserToRoom'; +import './scopeAdminRoomsForAbac'; diff --git a/apps/meteor/ee/server/hooks/abac/scopeAdminRoomsForAbac.ts b/apps/meteor/ee/server/hooks/abac/scopeAdminRoomsForAbac.ts new file mode 100644 index 0000000000000..9372a6620b039 --- /dev/null +++ b/apps/meteor/ee/server/hooks/abac/scopeAdminRoomsForAbac.ts @@ -0,0 +1,22 @@ +import { Abac } from '@rocket.chat/core-services'; +import { License } from '@rocket.chat/license'; +import { Users } from '@rocket.chat/models'; + +import { scopeAdminRoomsForAbac } from '../../../../app/api/server/lib/scopeAdminRoomsForAbac'; +import { isABACManagedRoom } from '../../../../app/authorization/server/lib/isABACManagedRoom'; + +scopeAdminRoomsForAbac.patch(async (next, rooms, uid) => { + const managed = License.hasModule('abac') ? rooms.filter(isABACManagedRoom) : []; + if (!managed.length) { + return next(rooms, uid); + } + + const user = await Users.findOneById(uid, { projection: { _id: 1, username: 1, name: 1 } }); + if (!user) { + return rooms.map((room) => (isABACManagedRoom(room) ? { ...room, abacAttributes: [], abacAttributesRedacted: true } : room)); + } + + const scoped = await Abac.scopeRoomsForAdmin(managed, user); + const scopedById = new Map(scoped.map((room) => [room._id, room])); + return rooms.map((room) => scopedById.get(room._id) ?? room); +}); diff --git a/apps/meteor/ee/server/lib/abac/index.ts b/apps/meteor/ee/server/lib/abac/index.ts index e6c5599582c6e..5132b5b93278f 100644 --- a/apps/meteor/ee/server/lib/abac/index.ts +++ b/apps/meteor/ee/server/lib/abac/index.ts @@ -7,6 +7,7 @@ export const createPermissions = async () => { { _id: 'manage-abac-admin-room-attributes', roles: ['admin'] }, { _id: 'manage-abac-admin-rooms', roles: ['admin'] }, { _id: 'view-abac-admin-audit', roles: ['admin'] }, + { _id: 'bypass-abac-store-validation', roles: [] }, ]; for (const permission of permissions) { diff --git a/apps/meteor/ee/server/settings/abac.ts b/apps/meteor/ee/server/settings/abac.ts index 6dcbaf93c2f83..39089d7998b84 100644 --- a/apps/meteor/ee/server/settings/abac.ts +++ b/apps/meteor/ee/server/settings/abac.ts @@ -29,6 +29,19 @@ export function addSettings(): Promise { ], enableQuery: abacEnabledQuery, }); + await this.add('ABAC_Attribute_Store', 'local', { + type: 'select', + public: true, + section: 'ABAC_Virtru_PDP_Configuration', + invalidValue: 'local', + values: [ + { key: 'local', i18nLabel: 'ABAC_Attribute_Store_Local' }, + { key: 'virtru', i18nLabel: 'ABAC_Attribute_Store_Virtru' }, + ], + i18nDescription: 'ABAC_Attribute_Store_Description', + alert: 'ABAC_Attribute_Store_Switch_Alert', + enableQuery: virtruPdpQuery, + }); await this.add('ABAC_ShowAttributesInRooms', false, { type: 'boolean', public: true, diff --git a/apps/meteor/tests/data/mock-server.helper.ts b/apps/meteor/tests/data/mock-server.helper.ts index 8a3c3077c636a..e1f2959efb9a4 100644 --- a/apps/meteor/tests/data/mock-server.helper.ts +++ b/apps/meteor/tests/data/mock-server.helper.ts @@ -71,6 +71,16 @@ export const seedGetDecisionBulk = async ( await mockServerSet('POST', '/authorization.v2.AuthorizationService/GetDecisionBulk', { decisionResponses: responses }, 200, times); }; +export const seedGetEntitlements = async (fqnMap: Record, times = 0) => { + await mockServerSet( + 'POST', + '/authorization.v2.AuthorizationService/GetEntitlements', + { entitlements: [{ actionsPerAttributeValueFqn: fqnMap }] }, + 200, + times, + ); +}; + export const seedBulkDecisionByEntity = async (permitValues: string[], defaultDecision: Decision = 'DECISION_DENY') => { const res = await fetch(`${MOCK_SERVER_URL}/__mock/set-bulk-decision`, { method: 'POST', diff --git a/apps/meteor/tests/end-to-end/api/abac.ts b/apps/meteor/tests/end-to-end/api/abac.ts index 04c803ccb234d..661faa01bad5d 100644 --- a/apps/meteor/tests/end-to-end/api/abac.ts +++ b/apps/meteor/tests/end-to-end/api/abac.ts @@ -14,6 +14,7 @@ import { seedBulkDecisionByEntity, seedDefaultMocks, seedGetDecisionBulk, + seedGetEntitlements, } from '../../data/mock-server.helper'; import { updatePermission, updateSetting } from '../../data/permissions.helper'; import { createRoom, deleteRoom } from '../../data/rooms.helper'; @@ -3518,4 +3519,757 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I expect(res.body.user).to.not.have.property('abacAttributes'); }); }); + + (IS_EE ? describe : describe.skip)('[ABAC] virtru-attribute-store (mock-server)', function () { + this.retries(0); + + const v1 = '/api/v1'; + const NS = 'example.com'; + const fqn = (key: string, value: string) => `https://${NS}/attr/${key}/value/${value}`; + const adminPassword = password; + let storeConnection: MongoClient; + + const makeAdmin = async (slug: string) => { + const u = await createUser({ roles: ['admin'], username: `vstore-${slug}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}` }); + const creds = await login(u.username, adminPassword); + return { user: u, creds }; + }; + + before((done) => getCredentials(done)); + + before(async function () { + this.timeout(20000); + + const healthy = await mockServerHealthy(); + expect(healthy, 'mock-server is not reachable — ensure it is running').to.be.true; + + storeConnection = await MongoClient.connect(URL_MONGODB); + + await Promise.all([ + updatePermission('abac-management', ['admin']), + updatePermission('manage-abac-admin-settings', ['admin']), + updatePermission('manage-abac-admin-room-attributes', ['admin']), + updatePermission('manage-abac-admin-rooms', ['admin']), + updatePermission('view-abac-admin-audit', ['admin']), + ]); + + await Promise.all([ + updateSetting('ABAC_Virtru_Base_URL', 'http://mock-server:8080'), + updateSetting('ABAC_Virtru_OIDC_Endpoint', 'http://mock-server:8080/auth/realms/mock'), + updateSetting('ABAC_Virtru_Client_ID', 'mock-client'), + updateSetting('ABAC_Virtru_Client_Secret', 'mock-secret'), + updateSetting('ABAC_Virtru_Default_Entity_Key', 'emailAddress'), + updateSetting('ABAC_Virtru_Attribute_Namespace', NS), + updateSetting('Abac_Cache_Decision_Time_Seconds', 0), + ]); + + await updateSetting('ABAC_Enabled', true); + await updateSetting('ABAC_PDP_Type', 'virtru'); + await updateSetting('ABAC_Attribute_Store', 'virtru'); + }); + + after(async function () { + this.timeout(15000); + + await mockServerReset(); + await updateSetting('ABAC_Attribute_Store', 'local'); + await updateSetting('ABAC_PDP_Type', 'local'); + await updateSetting('ABAC_Enabled', false); + await storeConnection.close(); + }); + + describe('redactor / single-room (spec §5.0/§5.0a)', () => { + let room: IRoom; + let adminA: { user: IUser; creds: Credentials }; + let adminB: { user: IUser; creds: Credentials }; + + before(async function () { + this.timeout(20000); + + adminA = await makeAdmin('redA'); + adminB = await makeAdmin('redB'); + + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} }); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: '__seed__' }] }]); + + room = (await createRoom({ type: 'p', name: `vstore-red-${Date.now()}` })).body.group; + + await storeConnection + .db() + .collection('rocketchat_room') + .updateOne({ _id: room._id as any }, { $set: { abacAttributes: [{ key: 'clearance', values: ['secret'] }] } }); + }); + + after(async () => { + await mockServerReset(); + await deleteRoom({ type: 'p', roomId: room._id }); + await deleteUser(adminA.user); + await deleteUser(adminB.user); + }); + + it('admin A (PERMIT): /abac/rooms returns room with abacAttributes populated, no redacted flag', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} }); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }]); + + const res = await request.get(`${v1}/abac/rooms`).set(adminA.creds).expect(200); + const found = (res.body.rooms as IRoom[]).find((r) => r._id === room._id); + expect(found, 'room must be in list for permit admin').to.exist; + expect(found?.abacAttributes).to.deep.equal([{ key: 'clearance', values: ['secret'] }]); + expect(found).to.not.have.property('abacAttributesRedacted'); + }); + + it('admin A (PERMIT): rooms.adminRooms.getRoom returns room with abacAttributes populated, no flag', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} }); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }]); + + const res = await request.get(`${v1}/rooms.adminRooms.getRoom`).set(adminA.creds).query({ rid: room._id }).expect(200); + expect(res.body.abacAttributes).to.deep.equal([{ key: 'clearance', values: ['secret'] }]); + expect(res.body).to.not.have.property('abacAttributesRedacted'); + }); + + it('admin A can POST a value they possess and gets 200', async () => { + const postRoom = (await createRoom({ type: 'p', name: `vstore-red-post-${Date.now()}` })).body.group; + + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} }); + await seedGetDecisionBulk([ + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: postRoom._id }] }, + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: postRoom._id }] }, + ]); + + await request + .post(`${v1}/abac/rooms/${postRoom._id}/attributes/clearance`) + .set(adminA.creds) + .send({ values: ['secret'] }) + .expect(200); + }); + + it('admin B (DENY): list contains the room redacted + flagged, same pagination shape as A', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('other', 'value')]: {} }); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_DENY', ephemeralResourceId: room._id }] }]); + + const resB = await request.get(`${v1}/abac/rooms`).set(adminB.creds).expect(200); + + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} }); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }]); + + const resA = await request.get(`${v1}/abac/rooms`).set(adminA.creds).expect(200); + + expect(resB.body.total).to.equal(resA.body.total); + expect(resB.body.offset).to.equal(resA.body.offset); + + const denied = (resB.body.rooms as IRoom[]).find((r) => r._id === room._id); + expect(denied, 'room must still appear in deny admin list').to.exist; + expect(denied?.abacAttributes).to.deep.equal([]); + expect(denied).to.have.property('abacAttributesRedacted', true); + + for (const r of resB.body.rooms as Array) { + expect(r).to.not.have.property('restricted'); + } + expect(resB.body).to.not.have.property('restricted'); + }); + + it('admin B (DENY): rooms.adminRooms.getRoom returns room redacted + flagged', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('other', 'value')]: {} }); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_DENY', ephemeralResourceId: room._id }] }]); + + const res = await request.get(`${v1}/rooms.adminRooms.getRoom`).set(adminB.creds).query({ rid: room._id }).expect(200); + expect(res.body.abacAttributes).to.deep.equal([]); + expect(res.body).to.have.property('abacAttributesRedacted', true); + }); + + it('admin B (DENY): non-empty POST → 400 error-abac-not-authorized-to-modify-room', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} }); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_DENY', ephemeralResourceId: room._id }] }]); + + const res = await request + .post(`${v1}/abac/rooms/${room._id}/attributes/clearance`) + .set(adminB.creds) + .send({ values: ['secret'] }) + .expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-abac-not-authorized-to-modify-room'); + }); + + it('Virtru unreachable on list → 200 with rooms attribute-redacted+flagged (not 4xx/5xx)', async () => { + await mockServerReset(); + await mockServerSet('GET', '/healthz', { status: 'NOT_SERVING' }, 503); + + const res = await request.get(`${v1}/abac/rooms`).set(adminA.creds).expect(200); + const found = (res.body.rooms as IRoom[]).find((r) => r._id === room._id); + expect(found, 'room remains in list when PDP is unreachable').to.exist; + expect(found?.abacAttributes).to.deep.equal([]); + expect(found).to.have.property('abacAttributesRedacted', true); + }); + }); + + describe('write guard / validateAssignable (spec §4.1)', () => { + let room: IRoom; + let adminWG: { user: IUser; creds: Credentials }; + + before(async function () { + this.timeout(15000); + adminWG = await makeAdmin('wg'); + room = (await createRoom({ type: 'p', name: `vstore-wg-${Date.now()}` })).body.group; + }); + + after(async () => { + await mockServerReset(); + await deleteRoom({ type: 'p', roomId: room._id }); + await deleteUser(adminWG.user); + }); + + it('assigning a key the admin lacks → 400 error-invalid-attribute-values', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} }); + await seedGetDecisionBulk([ + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, + ]); + + const res = await request + .post(`${v1}/abac/rooms/${room._id}/attributes/team`) + .set(adminWG.creds) + .send({ values: ['blue'] }) + .expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-invalid-attribute-values'); + }); + + it('assigning a value of a key they have but not that specific value → 400 error-invalid-attribute-values', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} }); + await seedGetDecisionBulk([ + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, + ]); + + const res = await request + .post(`${v1}/abac/rooms/${room._id}/attributes/clearance`) + .set(adminWG.creds) + .send({ values: ['topsecret'] }) + .expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-invalid-attribute-values'); + }); + }); + + describe('bypass-abac-store-validation permission (spec §4.2)', () => { + let adminBypass: { user: IUser; creds: Credentials }; + + before(async function () { + this.timeout(15000); + adminBypass = await makeAdmin('bypass'); + await updatePermission('bypass-abac-store-validation', ['admin']); + }); + + after(async () => { + await mockServerReset(); + await updatePermission('bypass-abac-store-validation', []); + await deleteUser(adminBypass.user); + }); + + it('skips validateAssignable: assigns a value the admin is not entitled to → 200', async () => { + const room = (await createRoom({ type: 'p', name: `vstore-bypass-wg-${Date.now()}` })).body.group; + + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} }); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }]); + + await request + .post(`${v1}/abac/rooms/${room._id}/attributes/team`) + .set(adminBypass.creds) + .send({ values: ['blue'] }) + .expect(200); + + await deleteRoom({ type: 'p', roomId: room._id }); + }); + + it('skips assertCanModifyRoom: edits an attribute on a room the PDP denies → 200', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} }); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: '__seed__' }] }]); + + const room = (await createRoom({ type: 'p', name: `vstore-bypass-mod-${Date.now()}` })).body.group; + await storeConnection + .db() + .collection('rocketchat_room') + .updateOne({ _id: room._id as any }, { $set: { abacAttributes: [{ key: 'clearance', values: ['secret'] }] } }); + + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} }); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_DENY', ephemeralResourceId: room._id }] }]); + + await request + .post(`${v1}/abac/rooms/${room._id}/attributes/team`) + .set(adminBypass.creds) + .send({ values: ['blue'] }) + .expect(200); + + await deleteRoom({ type: 'p', roomId: room._id }); + }); + }); + + describe('hierarchy expansion (spec §3.3)', () => { + let room: IRoom; + let adminH: { user: IUser; creds: Credentials }; + + before(async function () { + this.timeout(15000); + adminH = await makeAdmin('hier'); + room = (await createRoom({ type: 'p', name: `vstore-hier-${Date.now()}` })).body.group; + + await storeConnection + .db() + .collection('rocketchat_room') + .updateOne({ _id: room._id as any }, { $set: { abacAttributes: [{ key: 'clearance', values: ['secret'] }] } }); + }); + + after(async () => { + await mockServerReset(); + await deleteRoom({ type: 'p', roomId: room._id }); + await deleteUser(adminH.user); + }); + + it('picker returns hierarchy-expanded set (secret + topsecret) when entitled to topsecret', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ + [fqn('clearance', 'secret')]: {}, + [fqn('clearance', 'topsecret')]: {}, + }); + + const res = await request.get(`${v1}/abac/attributes`).set(adminH.creds).expect(200); + const clearance = (res.body.attributes as Array<{ key: string; values: string[] }>).find((a) => a.key === 'clearance'); + expect(clearance, 'clearance attribute should be present').to.exist; + expect(clearance?.values).to.include.members(['secret', 'topsecret']); + }); + + it('a room using a lower-rank value (secret) is in-scope for an actor entitled to the higher rank', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ + [fqn('clearance', 'secret')]: {}, + [fqn('clearance', 'topsecret')]: {}, + }); + await seedGetDecisionBulk([ + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, + ]); + + const res = await request.get(`${v1}/abac/rooms`).set(adminH.creds).expect(200); + const found = (res.body.rooms as IRoom[]).find((r) => r._id === room._id); + expect(found, 'lower-rank room must be in scope when entitled to higher rank').to.exist; + expect(found?.abacAttributes).to.deep.equal([{ key: 'clearance', values: ['secret'] }]); + expect(found).to.not.have.property('abacAttributesRedacted'); + + await request + .put(`${v1}/abac/rooms/${room._id}/attributes/clearance`) + .set(adminH.creds) + .send({ values: ['secret'] }) + .expect(200); + }); + }); + + describe('catalog-400 in virtru store (spec §5.1, corrected to 400)', () => { + let adminC: { user: IUser; creds: Credentials }; + + before(async function () { + this.timeout(15000); + adminC = await makeAdmin('cat'); + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} }); + }); + + after(async () => { + await mockServerReset(); + await deleteUser(adminC.user); + }); + + it('POST /abac/attributes → 400 error-abac-attribute-store-external', async () => { + const res = await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: `vstore_cat_${Date.now()}`, values: ['v1'] }) + .expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-abac-attribute-store-external'); + }); + + it('PUT /abac/attributes/:_id → 400 error-abac-attribute-store-external', async () => { + const res = await request + .put(`${v1}/abac/attributes/__nonexistent__`) + .set(credentials) + .send({ values: ['v1'] }) + .expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-abac-attribute-store-external'); + }); + + it('DELETE /abac/attributes/:_id → 400 error-abac-attribute-store-external', async () => { + const res = await request.delete(`${v1}/abac/attributes/__nonexistent__`).set(credentials).expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-abac-attribute-store-external'); + }); + + it('GET /abac/attributes/:_id → 400 error-abac-attribute-store-external', async () => { + const res = await request.get(`${v1}/abac/attributes/__nonexistent__`).set(credentials).expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-abac-attribute-store-external'); + }); + + it('GET /abac/attributes/:key/is-in-use → 400 error-abac-attribute-store-external', async () => { + const res = await request.get(`${v1}/abac/attributes/clearance/is-in-use`).set(credentials).expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-abac-attribute-store-external'); + }); + + it('GET /abac/attributes (picker) → 200 served from store.list(actor)', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} }); + + const res = await request.get(`${v1}/abac/attributes`).set(adminC.creds).expect(200); + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('attributes').that.is.an('array'); + const clearance = (res.body.attributes as Array<{ key: string; values: string[] }>).find((a) => a.key === 'clearance'); + expect(clearance?.values).to.deep.equal(['secret']); + }); + + it('POST /abac/users/sync is NOT blocked by external attribute store (no error-abac-attribute-store-external)', async () => { + const res = await request + .post(`${v1}/abac/users/sync`) + .set(credentials) + .send({ usernames: ['no-such-user-vstore'] }); + expect(res.body?.error).to.not.equal('error-abac-attribute-store-external'); + }); + }); + + describe('picker / validateAssignable unreachable', () => { + let room: IRoom; + let adminU: { user: IUser; creds: Credentials }; + + before(async function () { + this.timeout(15000); + adminU = await makeAdmin('unr'); + room = (await createRoom({ type: 'p', name: `vstore-unr-${Date.now()}` })).body.group; + + await storeConnection + .db() + .collection('rocketchat_room') + .updateOne({ _id: room._id as any }, { $set: { abacAttributes: [{ key: 'clearance', values: ['secret'] }] } }); + }); + + after(async () => { + await mockServerReset(); + await deleteRoom({ type: 'p', roomId: room._id }); + await deleteUser(adminU.user); + }); + + it('Virtru unreachable: picker GET /abac/attributes → 400 error-pdp-unavailable', async () => { + await mockServerReset(); + await mockServerSet('GET', '/healthz', { status: 'NOT_SERVING' }, 503); + + const res = await request.get(`${v1}/abac/attributes`).set(adminU.creds).expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-pdp-unavailable'); + }); + + it('Virtru unreachable: write POST /abac/rooms/:rid/attributes/:key → 400 error-pdp-unavailable', async () => { + await mockServerReset(); + await mockServerSet('GET', '/healthz', { status: 'NOT_SERVING' }, 503); + + const res = await request + .post(`${v1}/abac/rooms/${room._id}/attributes/clearance`) + .set(adminU.creds) + .send({ values: ['secret'] }) + .expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-pdp-unavailable'); + }); + + it('decision unreachable on rooms.adminRooms.getRoom → 200 with abacAttributes redacted+flagged', async () => { + await mockServerReset(); + await mockServerSet('GET', '/healthz', { status: 'NOT_SERVING' }, 503); + + const res = await request.get(`${v1}/rooms.adminRooms.getRoom`).set(adminU.creds).query({ rid: room._id }).expect(200); + expect(res.body.abacAttributes).to.deep.equal([]); + expect(res.body).to.have.property('abacAttributesRedacted', true); + }); + + it('write assertCanModifyRoom unreachable → 400 error-pdp-unavailable', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn('clearance', 'secret')]: {} }); + await mockServerSet('POST', '/authorization.v2.AuthorizationService/GetDecisionBulk', { error: 'pdp-down' }, 503); + + const res = await request + .put(`${v1}/abac/rooms/${room._id}/attributes/clearance`) + .set(adminU.creds) + .send({ values: ['secret'] }) + .expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-pdp-unavailable'); + }); + }); + + describe('wipe on transition local → virtru (spec §0.2)', () => { + let wipeRoom1: IRoom; + let wipeRoom2: IRoom; + let memberUser: IUser; + + before(async function () { + this.timeout(20000); + + await updateSetting('ABAC_Attribute_Store', 'local'); + + const wipeAttrKey = `vstore_wipe_attr_${Date.now()}`; + + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn(wipeAttrKey, 'v1')]: {} }); + + wipeRoom1 = (await createRoom({ type: 'p', name: `vstore-wipe-1-${Date.now()}` })).body.group; + wipeRoom2 = (await createRoom({ type: 'p', name: `vstore-wipe-2-${Date.now()}` })).body.group; + + memberUser = await createUser(); + await request + .post(`${v1}/groups.invite`) + .set(credentials) + .send({ roomId: wipeRoom1._id, usernames: [memberUser.username] }) + .expect(200); + + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: wipeAttrKey, values: ['v1'] }) + .expect(200); + + await request + .post(`${v1}/abac/rooms/${wipeRoom1._id}/attributes/${wipeAttrKey}`) + .set(credentials) + .send({ values: ['v1'] }) + .expect(200); + await request + .post(`${v1}/abac/rooms/${wipeRoom2._id}/attributes/${wipeAttrKey}`) + .set(credentials) + .send({ values: ['v1'] }) + .expect(200); + }); + + after(async () => { + await updateSetting('ABAC_Attribute_Store', 'virtru'); + await deleteRoom({ type: 'p', roomId: wipeRoom1._id }); + await deleteRoom({ type: 'p', roomId: wipeRoom2._id }); + await deleteUser(memberUser); + }); + + it('switching ABAC_Attribute_Store local → virtru wipes abacAttributes from existing rooms', async function () { + this.timeout(20000); + + await updateSetting('ABAC_Attribute_Store', 'virtru'); + + const rooms = storeConnection.db().collection('rocketchat_room'); + let r1: any = null; + let r2: any = null; + for (let i = 0; i < 30; i++) { + r1 = await rooms.findOne({ _id: wipeRoom1._id as any }); + r2 = await rooms.findOne({ _id: wipeRoom2._id as any }); + if (!r1?.abacAttributes && !r2?.abacAttributes) { + break; + } + await sleep(200); + } + expect(r1?.abacAttributes, 'room1 abacAttributes must be unset after transition').to.be.undefined; + expect(r2?.abacAttributes, 'room2 abacAttributes must be unset after transition').to.be.undefined; + }); + + it('emits at-least-one abac.attribute.store.switched audit event with from=local, to=virtru, roomsAffected>=1', async function () { + this.timeout(15000); + + let switched: Array<{ data: Array<{ key: string; value: unknown }> }> = []; + for (let i = 0; i < 30; i++) { + const res = await request.get(`${v1}/abac/audit`).set(credentials).query({ count: 100 }).expect(200); + switched = (res.body.events as Array<{ t: string; data: Array<{ key: string; value: unknown }> }>).filter( + (e) => e.t === 'abac.attribute.store.switched', + ); + if (switched.length) { + break; + } + await sleep(200); + } + expect(switched.length, 'at least one switched audit event').to.be.at.least(1); + const ev = switched[0]; + const pick = (k: string) => ev.data.find((d) => d.key === k)?.value; + expect(pick('from')).to.equal('local'); + expect(pick('to')).to.equal('virtru'); + expect(pick('roomsAffected')).to.be.a('number').and.to.be.at.least(1); + }); + + it('members of pre-existing ABAC rooms are NOT evicted by the wipe', async () => { + const res = await request.get(`${v1}/groups.members`).set(credentials).query({ roomId: wipeRoom1._id }).expect(200); + const usernames = res.body.members.map((m: IUser) => m.username); + expect(usernames).to.include(memberUser.username); + }); + }); + + describe('audit / logs (spec §5.2 — settled, NOT redacted)', () => { + let auditRoom: IRoom; + const auditAttrKey = `vstore_audit_${Date.now()}`; + + before(async function () { + this.timeout(15000); + await mockServerReset(); + await seedDefaultMocks(); + await seedGetEntitlements({ [fqn(auditAttrKey, 'v1')]: {} }); + await seedGetDecisionBulk([ + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: '__seed__' }] }, + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: '__seed__' }] }, + ]); + + auditRoom = (await createRoom({ type: 'p', name: `vstore-audit-${Date.now()}` })).body.group; + }); + + after(async () => { + await mockServerReset(); + await deleteRoom({ type: 'p', roomId: auditRoom._id }); + }); + + it('GET /abac/audit returns events with attribute key/value payloads visible (not redacted) and includes abac.attribute.store.switched', async () => { + const res = await request.get(`${v1}/abac/audit`).set(credentials).query({ count: 200 }).expect(200); + + const events = res.body.events as Array<{ t: string; data: Array<{ key: string; value: unknown }> }>; + expect(events).to.be.an('array').that.is.not.empty; + + const types = new Set(events.map((e) => e.t)); + expect(types.has('abac.attribute.store.switched'), 'attribute_store.switched event included by endpoint').to.be.true; + + const switched = events.find((e) => e.t === 'abac.attribute.store.switched'); + expect(switched, 'switched event must exist').to.exist; + expect(switched?.data.find((d) => d.key === 'from')?.value, 'from value visible').to.be.a('string'); + expect(switched?.data.find((d) => d.key === 'to')?.value, 'to value visible').to.be.a('string'); + expect(switched?.data.find((d) => d.key === 'roomsAffected')?.value, 'roomsAffected visible').to.be.a('number'); + }); + }); + + describe('local-mode regression (ABAC_Attribute_Store=local)', () => { + const localKey = `vstore_local_${Date.now()}`; + let localRoom: IRoom; + let localAttrId: string; + + before(async function () { + this.timeout(20000); + + await updateSetting('ABAC_Attribute_Store', 'local'); + + await mockServerReset(); + await seedDefaultMocks(); + + const createRes = await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: localKey, values: ['v1', 'v2'] }) + .expect(200); + expect(createRes.body).to.have.property('success', true); + + const listRes = await request.get(`${v1}/abac/attributes`).query({ key: localKey }).set(credentials).expect(200); + const attr = (listRes.body.attributes as Array<{ _id: string; key: string }>).find((a) => a.key === localKey); + expect(attr, 'created local attribute should be retrievable via picker').to.exist; + if (!attr) { + throw new Error('local attribute not found'); + } + localAttrId = attr._id; + + localRoom = (await createRoom({ type: 'p', name: `vstore-local-${Date.now()}` })).body.group; + await request + .post(`${v1}/abac/rooms/${localRoom._id}/attributes/${localKey}`) + .set(credentials) + .send({ values: ['v1'] }) + .expect(200); + }); + + after(async () => { + await request.delete(`${v1}/abac/rooms/${localRoom._id}/attributes`).set(credentials).expect(200); + await deleteRoom({ type: 'p', roomId: localRoom._id }); + await request.delete(`${v1}/abac/attributes/${localAttrId}`).set(credentials).expect(200); + await updateSetting('ABAC_Attribute_Store', 'virtru'); + }); + + it('catalog CRUD works (PUT 200)', async () => { + await request + .put(`${v1}/abac/attributes/${localAttrId}`) + .set(credentials) + .send({ values: ['v1', 'v2', 'v3'] }) + .expect(200); + }); + + it('GET /abac/attributes/:_id returns 200 (no virtru block)', async () => { + const res = await request.get(`${v1}/abac/attributes/${localAttrId}`).set(credentials).expect(200); + expect(res.body).to.have.property('success', true); + }); + + it('GET /abac/attributes/:key/is-in-use returns 200 in local mode', async () => { + const res = await request.get(`${v1}/abac/attributes/${localKey}/is-in-use`).set(credentials).expect(200); + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('inUse').that.is.a('boolean'); + }); + + it('list /abac/rooms returns rooms with full attributes, no abacAttributesRedacted flags', async () => { + const res = await request.get(`${v1}/abac/rooms`).set(credentials).expect(200); + const found = (res.body.rooms as IRoom[]).find((r) => r._id === localRoom._id); + expect(found, 'local-mode room must be in list').to.exist; + expect(found?.abacAttributes).to.deep.equal([{ key: localKey, values: ['v1'] }]); + for (const r of res.body.rooms as IRoom[]) { + expect(r).to.not.have.property('abacAttributesRedacted'); + } + }); + + it('write guard is permissive in local mode (admin without entitlements still succeeds)', async () => { + await request + .put(`${v1}/abac/rooms/${localRoom._id}/attributes/${localKey}`) + .set(credentials) + .send({ values: ['v2'] }) + .expect(200); + }); + + it('local→local no-op setting write does NOT run the wipe (no new switched audit event)', async function () { + this.timeout(10000); + + const before = await request.get(`${v1}/abac/audit`).set(credentials).query({ count: 200 }).expect(200); + const beforeCount = (before.body.events as Array<{ t: string }>).filter((e) => e.t === 'abac.attribute.store.switched').length; + + await updateSetting('ABAC_Attribute_Store', 'local'); + + const after = await request.get(`${v1}/abac/audit`).set(credentials).query({ count: 200 }).expect(200); + const afterCount = (after.body.events as Array<{ t: string }>).filter((e) => e.t === 'abac.attribute.store.switched').length; + + expect(afterCount).to.equal(beforeCount); + + const roomRes = await request.get(`${v1}/abac/rooms`).set(credentials).expect(200); + const stillThere = (roomRes.body.rooms as IRoom[]).find((r) => r._id === localRoom._id); + expect(stillThere?.abacAttributes, 'no-op local→local must not wipe').to.deep.equal([{ key: localKey, values: ['v2'] }]); + }); + }); + }); }); diff --git a/ee/packages/abac/src/audit.ts b/ee/packages/abac/src/audit.ts index ad49c115d4340..11dc0e16d7395 100644 --- a/ee/packages/abac/src/audit.ts +++ b/ee/packages/abac/src/audit.ts @@ -7,6 +7,7 @@ import type { AbacAuditServerEventKey, AbacAttributeDefinitionChangeType, AbacAuditReason, + AbacAttributeStoreType, AbacPdpType, MinimalRoom, MinimalUser, @@ -148,4 +149,7 @@ export const Audit = { { type: 'system' }, ); }, + attributeStoreSwitched: async (from: AbacAttributeStoreType, to: AbacAttributeStoreType, roomsAffected: number) => { + return audit('abac.attribute.store.switched', { from, to, reason: 'attribute-store-switch', roomsAffected }, { type: 'system' }); + }, }; diff --git a/ee/packages/abac/src/clients/virtru/VirtruClient.spec.ts b/ee/packages/abac/src/clients/virtru/VirtruClient.spec.ts new file mode 100644 index 0000000000000..0b62326b5e141 --- /dev/null +++ b/ee/packages/abac/src/clients/virtru/VirtruClient.spec.ts @@ -0,0 +1,90 @@ +import { VirtruClient } from './VirtruClient'; + +const serverFetchMock = jest.fn(); +jest.mock('@rocket.chat/server-fetch', () => ({ serverFetch: (...a: unknown[]) => serverFetchMock(...a) })); + +const cfg = { + baseUrl: 'http://pdp', + clientId: 'cid', + clientSecret: 'sec', + oidcEndpoint: 'http://oidc', + defaultEntityKey: 'emailAddress' as const, + attributeNamespace: 'example.com', +}; + +const okJson = (body: unknown) => ({ ok: true, status: 200, json: async () => body, text: async () => '' }); + +beforeEach(() => serverFetchMock.mockReset()); + +describe('VirtruClient', () => { + it('caches the OIDC token across apiCalls (one token fetch serves many calls)', async () => { + serverFetchMock.mockResolvedValueOnce(okJson({ access_token: 'tok', expires_in: 3600 })).mockResolvedValue(okJson({})); + const c = new VirtruClient(cfg); + await c.apiCall('/x', {}); + await c.apiCall('/y', {}); + const tokenCalls = serverFetchMock.mock.calls.filter(([url]) => String(url).includes('openid-connect/token')); + expect(tokenCalls).toHaveLength(1); + }); + + it('updateConfig resets the token cache', async () => { + serverFetchMock.mockResolvedValue(okJson({ access_token: 'tok', expires_in: 3600 })); + const c = new VirtruClient(cfg); + await c.apiCall('/x', {}); + c.updateConfig({ ...cfg, clientSecret: 'new' }); + await c.apiCall('/x', {}); + const tokenCalls = serverFetchMock.mock.calls.filter(([url]) => String(url).includes('openid-connect/token')); + expect(tokenCalls).toHaveLength(2); + }); + + it('isAvailable true only when /healthz status SERVING', async () => { + const c = new VirtruClient(cfg); + serverFetchMock.mockResolvedValueOnce(okJson({ status: 'SERVING' })); + expect(await c.isAvailable()).toBe(true); + serverFetchMock.mockResolvedValueOnce(okJson({ status: 'NOT_SERVING' })); + expect(await c.isAvailable()).toBe(false); + serverFetchMock.mockRejectedValueOnce(new Error('down')); + expect(await c.isAvailable()).toBe(false); + }); + + it('apiCall sends Bearer token', async () => { + serverFetchMock.mockResolvedValueOnce(okJson({ access_token: 'tok', expires_in: 3600 })).mockResolvedValueOnce(okJson({})); + const c = new VirtruClient(cfg); + await c.apiCall('/x', {}); + const apiCallArgs = serverFetchMock.mock.calls.find(([url]) => String(url) === 'http://pdp/x'); + expect((apiCallArgs?.[1] as any).headers.Authorization).toBe('Bearer tok'); + }); + + it('getClientTokenForHealthCheck resets the cache then returns a freshly fetched token', async () => { + serverFetchMock + .mockResolvedValueOnce(okJson({ access_token: 'tok1', expires_in: 3600 })) + .mockResolvedValueOnce(okJson({})) + .mockResolvedValueOnce(okJson({ access_token: 'tok2', expires_in: 3600 })); + const c = new VirtruClient(cfg); + await c.apiCall('/x', {}); + const token = await c.getClientTokenForHealthCheck(); + expect(token).toBe('tok2'); + const tokenCalls = serverFetchMock.mock.calls.filter(([url]) => String(url).includes('openid-connect/token')); + expect(tokenCalls).toHaveLength(2); + }); + + it('getConfig exposes only baseUrl, defaultEntityKey, attributeNamespace and clientId', () => { + const c = new VirtruClient(cfg); + const pub = c.getConfig(); + expect(pub).toEqual({ + baseUrl: cfg.baseUrl, + defaultEntityKey: cfg.defaultEntityKey, + attributeNamespace: cfg.attributeNamespace, + clientId: cfg.clientId, + }); + expect(pub).not.toHaveProperty('clientSecret'); + expect(pub).not.toHaveProperty('oidcEndpoint'); + }); + + it('apiCall throws on non-ok response', async () => { + serverFetchMock + .mockResolvedValueOnce(okJson({ access_token: 'tok', expires_in: 3600 })) + .mockResolvedValueOnce({ ok: false, status: 500, text: async () => 'boom' }); + const c = new VirtruClient(cfg); + await expect(c.apiCall('/x', {})).rejects.toThrow('Virtru PDP call failed'); + }); +}); diff --git a/ee/packages/abac/src/clients/virtru/VirtruClient.ts b/ee/packages/abac/src/clients/virtru/VirtruClient.ts new file mode 100644 index 0000000000000..5d3d029c4a00a --- /dev/null +++ b/ee/packages/abac/src/clients/virtru/VirtruClient.ts @@ -0,0 +1,117 @@ +import { serverFetch } from '@rocket.chat/server-fetch'; + +import { logger } from '../../logger'; +import type { IVirtruPDPConfig, ITokenCache } from '../../pdp/types'; + +const virtruClientLogger = logger.section('VirtruClient'); + +export const HEALTH_CHECK_TIMEOUT = 5000; +const REQUEST_TIMEOUT = 10000; + +type PublicVirtruConfig = Pick; + +export class VirtruClient { + private tokenCache: ITokenCache | null = null; + + private config: IVirtruPDPConfig; + + constructor(config: IVirtruPDPConfig) { + this.config = config; + } + + updateConfig(config: IVirtruPDPConfig): void { + this.config = config; + this.tokenCache = null; + } + + getConfig(): PublicVirtruConfig { + const { baseUrl, defaultEntityKey, attributeNamespace, clientId } = this.config; + return { baseUrl, defaultEntityKey, attributeNamespace, clientId }; + } + + async isAvailable(): Promise { + try { + const response = await serverFetch(`${this.config.baseUrl}/healthz`, { + method: 'GET', + timeout: HEALTH_CHECK_TIMEOUT, + // SECURITY: This can only be configured by users with enough privileges. It's ok to disable this check here. + ignoreSsrfValidation: true, + }); + + if (!response.ok) { + throw new Error('PDP Health check failed'); + } + + const data = (await response.json()) as { status?: string }; + + virtruClientLogger.info({ msg: 'Virtru PDP health check response', data }); + return data.status === 'SERVING'; + } catch (err) { + virtruClientLogger.warn({ msg: 'Virtru PDP is not reachable', err }); + return false; + } + } + + private async getClientToken(): Promise { + if (this.tokenCache && Date.now() < this.tokenCache.expiresAt) { + return this.tokenCache.accessToken; + } + const response = await serverFetch(`${this.config.oidcEndpoint}/protocol/openid-connect/token`, { + method: 'POST', + timeout: REQUEST_TIMEOUT, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + }), + ignoreSsrfValidation: true, + }); + + if (!response.ok) { + throw new Error(`Failed to obtain client token: ${response.status} ${response.statusText}`); + } + + const data = (await response.json()) as { access_token: string; expires_in?: number }; + + const expiresIn = data.expires_in ?? 300; + this.tokenCache = { + accessToken: data.access_token, + expiresAt: Date.now() + (expiresIn - 30) * 1000, + }; + + return data.access_token; + } + + async getClientTokenForHealthCheck(): Promise { + this.tokenCache = null; + return this.getClientToken(); + } + + async apiCall(endpoint: string, body: unknown): Promise { + const token = await this.getClientToken(); + + virtruClientLogger.debug({ msg: 'Virtru PDP API call request', endpoint, body }); + + const response = await serverFetch(`${this.config.baseUrl}${endpoint}`, { + method: 'POST', + timeout: REQUEST_TIMEOUT, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(body), + ignoreSsrfValidation: true, + }); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + virtruClientLogger.error({ msg: 'Virtru PDP API call failed', endpoint, status: response.status, response: text }); + throw new Error('Virtru PDP call failed'); + } + + const data = (await response.json()) as T; + virtruClientLogger.debug({ msg: 'Virtru PDP API call response', endpoint, response: data }); + return data; + } +} diff --git a/ee/packages/abac/src/clients/virtru/identity.spec.ts b/ee/packages/abac/src/clients/virtru/identity.spec.ts new file mode 100644 index 0000000000000..2e4c1f3b211d2 --- /dev/null +++ b/ee/packages/abac/src/clients/virtru/identity.spec.ts @@ -0,0 +1,33 @@ +import { buildEntityIdentifier, getUserEntityKey, buildAttributeFqns, parseAttributeFqns } from './identity'; + +describe('virtru/identity', () => { + it('buildEntityIdentifier maps emailAddress vs oidcIdentifier', () => { + expect(buildEntityIdentifier('emailAddress', 'a@b.com')).toEqual({ emailAddress: 'a@b.com' }); + expect(buildEntityIdentifier('oidcIdentifier', 'bob')).toEqual({ id: 'bob' }); + }); + + it('getUserEntityKey resolves per defaultEntityKey', () => { + expect(getUserEntityKey('emailAddress', { _id: 'u', emails: [{ address: 'a@b.com', verified: true }], username: 'bob' })).toBe( + 'a@b.com', + ); + expect(getUserEntityKey('oidcIdentifier', { _id: 'u', emails: [], username: 'bob' })).toBe('bob'); + expect(getUserEntityKey('emailAddress', { _id: 'u', username: 'bob' })).toBeUndefined(); + }); + + it('buildAttributeFqns round-trips with parseAttributeFqns', () => { + const attrs = [{ key: 'clearance', values: ['secret', 'topsecret'] }]; + const fqns = buildAttributeFqns('example.com', attrs); + expect(fqns).toEqual(['https://example.com/attr/clearance/value/secret', 'https://example.com/attr/clearance/value/topsecret']); + expect(parseAttributeFqns(fqns).attributes).toEqual([{ key: 'clearance', values: ['secret', 'topsecret'] }]); + }); + + it('buildAttributeFqns throws when namespace empty', () => { + expect(() => buildAttributeFqns('', [{ key: 'k', values: ['v'] }])).toThrow(); + }); + + it('parseAttributeFqns returns malformed FQNs alongside parsed attributes', () => { + const { attributes, malformed } = parseAttributeFqns(['https://example.com/attr/k/value/v', 'not-a-fqn', 'https://example.com/broken']); + expect(malformed).toEqual(['not-a-fqn', 'https://example.com/broken']); + expect(attributes).toEqual([{ key: 'k', values: ['v'] }]); + }); +}); diff --git a/ee/packages/abac/src/clients/virtru/identity.ts b/ee/packages/abac/src/clients/virtru/identity.ts new file mode 100644 index 0000000000000..dfdca907b3a3d --- /dev/null +++ b/ee/packages/abac/src/clients/virtru/identity.ts @@ -0,0 +1,48 @@ +import type { IAbacAttributeDefinition, IUser } from '@rocket.chat/core-typings'; + +import type { IEntityIdentifier } from '../../pdp/types'; + +export type EntityKeyType = 'emailAddress' | 'oidcIdentifier'; + +export function buildEntityIdentifier(defaultEntityKey: EntityKeyType, entityKey: string): IEntityIdentifier { + if (defaultEntityKey === 'emailAddress') { + return { emailAddress: entityKey }; + } + return { id: entityKey }; +} + +export function getUserEntityKey(defaultEntityKey: EntityKeyType, user: Pick): string | undefined { + switch (defaultEntityKey) { + case 'emailAddress': + return user.emails?.[0]?.address; + case 'oidcIdentifier': + return user.username; + } +} + +export function buildAttributeFqns(attributeNamespace: string, attributes: IAbacAttributeDefinition[]): string[] { + if (!attributeNamespace) { + throw new Error('Attribute namespace is not configured for VirtruPDP'); + } + return attributes.flatMap((attr) => attr.values.map((value) => `https://${attributeNamespace}/attr/${attr.key}/value/${value}`)); +} + +const FQN_RE = /^https:\/\/[^/]+\/attr\/([^/]+)\/value\/(.+)$/; + +export function parseAttributeFqns(fqns: string[]): { attributes: IAbacAttributeDefinition[]; malformed: string[] } { + const grouped = new Map>(); + const malformed: string[] = []; + for (const fqn of fqns) { + const m = FQN_RE.exec(fqn); + if (!m) { + malformed.push(fqn); + continue; + } + const [, key, value] = m; + const bucket = grouped.get(key) ?? new Set(); + bucket.add(value); + grouped.set(key, bucket); + } + const attributes = [...grouped.entries()].map(([key, values]) => ({ key, values: [...values] })); + return { attributes, malformed }; +} diff --git a/ee/packages/abac/src/errors.ts b/ee/packages/abac/src/errors.ts index c4bbf6c0d5fbe..3ec365bc30963 100644 --- a/ee/packages/abac/src/errors.ts +++ b/ee/packages/abac/src/errors.ts @@ -13,6 +13,9 @@ export enum AbacErrorCode { AbacUnsupportedOperation = 'error-abac-unsupported-operation', OnlyCompliantCanBeAddedToRoom = 'error-only-compliant-users-can-be-added-to-abac-rooms', PdpUnavailable = 'error-pdp-unavailable', + AttributeStoreExternal = 'error-abac-attribute-store-external', + EntityResolutionFailed = 'error-virtru-entity-resolution-failed', + NotAuthorizedToModifyRoom = 'error-abac-not-authorized-to-modify-room', } export class AbacError extends Error { @@ -101,6 +104,24 @@ export class PdpUnavailableError extends AbacError { } } +export class AbacAttributeStoreExternalError extends AbacError { + constructor(details?: unknown) { + super(AbacErrorCode.AttributeStoreExternal, details); + } +} + +export class AbacEntityResolutionFailedError extends AbacError { + constructor(details?: unknown) { + super(AbacErrorCode.EntityResolutionFailed, details); + } +} + +export class AbacNotAuthorizedToModifyRoomError extends AbacError { + constructor(details?: unknown) { + super(AbacErrorCode.NotAuthorizedToModifyRoom, details); + } +} + export class PdpHealthCheckError extends MeteorError { constructor(errorCode: string) { super(errorCode, errorCode); diff --git a/ee/packages/abac/src/helper.ts b/ee/packages/abac/src/helper.ts index e3309be4d1abf..7b7b0808c4496 100644 --- a/ee/packages/abac/src/helper.ts +++ b/ee/packages/abac/src/helper.ts @@ -316,3 +316,5 @@ export function diffAttributeSets( return { added, removed }; } + +export const stripTrailingSlashes = (value: string): string => value.replace(/\/+$/, ''); diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 675376c23a9b5..1b73bb16b49a1 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -1,14 +1,17 @@ -import { api, Room, ServiceClass, Settings } from '@rocket.chat/core-services'; +import { api, Authorization, License, Room, ServiceClass, Settings } from '@rocket.chat/core-services'; import type { AbacActor, IAbacService } from '@rocket.chat/core-services'; -import { AbacAccessOperation, AbacObjectType } from '@rocket.chat/core-typings'; +import { AbacAccessOperation, AbacObjectType, isAbacPdpType, isAbacAttributeStoreType } from '@rocket.chat/core-typings'; import type { IAbacAttribute, IAbacAttributeDefinition, IRoom, + IRoomAbacRedaction, AtLeast, IUser, ILDAPEntry, AbacAuditReason, + AbacAttributeStoreType, + AbacPdpType, } from '@rocket.chat/core-typings'; import { Rooms, AbacAttributes, Users, Subscriptions } from '@rocket.chat/models'; import { escapeRegExp } from '@rocket.chat/string-helpers'; @@ -17,6 +20,7 @@ import type { Document, UpdateFilter } from 'mongodb'; import pLimit from 'p-limit'; import { Audit } from './audit'; +import { VirtruClient } from './clients/virtru/VirtruClient'; import { AbacAttributeInUseError, AbacAttributeNotFoundError, @@ -33,18 +37,18 @@ import { extractAttribute, diffAttributeSets, validateAndNormalizeAttributes, - ensureAttributeDefinitionsExist, MAX_ABAC_ATTRIBUTE_KEYS, + stripTrailingSlashes, } from './helper'; import { logger } from './logger'; import type { IPolicyDecisionPoint, VirtruPDPConfig } from './pdp'; import { LocalPDP, VirtruPDP } from './pdp'; +import { LocalAttributeStore, VirtruAttributeStore } from './store'; +import type { AttributeStoreDescriptor, AttributeStoreSelectionContext, IAttributeStore } from './store'; // Limit concurrent user removals to avoid overloading the server with too many operations at once const limit = pLimit(20); -const stripTrailingSlashes = (value: string): string => value.replace(/\/+$/, ''); - export class AbacService extends ServiceClass implements IAbacService { protected name = 'abac'; @@ -59,6 +63,24 @@ export class AbacService extends ServiceClass implements IAbacService { attributeNamespace: 'example.com', }; + private virtruClient = new VirtruClient(this.virtruPdpConfig); + + private abacEnabled?: boolean; + + private pdpTypeSetting?: AbacPdpType; + + private attributeStoreSetting?: AbacAttributeStoreType; + + private readonly attributeStores: Record = { + local: { store: new LocalAttributeStore(), isEligible: () => true }, + virtru: { + store: new VirtruAttributeStore(this.virtruClient), + isEligible: (ctx) => ctx.abacEnabled && ctx.pdpType === 'virtru' && ctx.licensed, + }, + }; + + private lastSelectedStore?: IAttributeStore; + decisionCacheTimeout = 60; // seconds constructor() { @@ -66,7 +88,7 @@ export class AbacService extends ServiceClass implements IAbacService { this.onSettingChanged('ABAC_PDP_Type', async ({ setting }): Promise => { const { value } = setting; - if (value !== 'local' && value !== 'virtru') { + if (!isAbacPdpType(value)) { return; } @@ -74,7 +96,35 @@ export class AbacService extends ServiceClass implements IAbacService { await this.loadVirtruPdpConfig(); } + const prevEffective = await this.computeEffectiveStoreType(); + this.pdpTypeSetting = value; this.setPdpStrategy(value); + await this.fireEffectiveStoreTransitionIfChanged(prevEffective); + if (value === 'local' && this.attributeStoreSetting === 'virtru') { + try { + await Settings.set('ABAC_Attribute_Store', 'local'); + } catch (err) { + logger.error({ msg: 'Failed to cascade ABAC_Attribute_Store=local on PDP change to local', err }); + } + } + }); + + this.onSettingChanged('ABAC_Enabled', async ({ setting }): Promise => { + this.abacEnabled = setting.value as boolean; + }); + + this.onSettingChanged('ABAC_Attribute_Store', async ({ setting }): Promise => { + const { value } = setting; + if (!isAbacAttributeStoreType(value)) { + return; + } + + const prevSetting = this.attributeStoreSetting; + const prevEffective = await this.computeEffectiveStoreType(); + this.attributeStoreSetting = value; + if (prevSetting !== undefined) { + await this.fireEffectiveStoreTransitionIfChanged(prevEffective); + } }); this.onSettingChanged('Abac_Cache_Decision_Time_Seconds', async ({ setting }): Promise => { @@ -137,17 +187,62 @@ export class AbacService extends ServiceClass implements IAbacService { } private syncVirtruPdpConfig(): void { - if (this.pdp instanceof VirtruPDP) { - this.pdp.updateConfig({ ...this.virtruPdpConfig }); + this.virtruClient.updateConfig({ ...this.virtruPdpConfig }); + this.lastSelectedStore = undefined; + } + + private async computeEffectiveStoreType(): Promise { + const ctx: AttributeStoreSelectionContext = { + abacEnabled: this.abacEnabled === true, + pdpType: this.pdpTypeSetting, + licensed: await License.hasModule('abac'), + }; + const selected = this.attributeStoreSetting ?? 'local'; + return this.attributeStores[selected].isEligible(ctx) ? selected : 'local'; + } + + private async fireEffectiveStoreTransitionIfChanged(prevEffective: AbacAttributeStoreType): Promise { + const nextEffective = await this.computeEffectiveStoreType(); + if (prevEffective === nextEffective) { + return; + } + void this.onAttributeStoreTransition(prevEffective, nextEffective).catch((err) => + logger.error({ msg: 'ABAC attribute-store switch handler failed', err }), + ); + } + + private async resolveAttributeStore(): Promise { + const effective = await this.computeEffectiveStoreType(); + const { store } = this.attributeStores[effective]; + + if (store !== this.lastSelectedStore) { + this.lastSelectedStore = store; + store.onStoreSelected?.(); + } + return store; + } + + async isExternalAttributeStore(): Promise { + return (await this.resolveAttributeStore()) !== this.attributeStores.local.store; + } + + private async onAttributeStoreTransition(from: AbacAttributeStoreType, to: AbacAttributeStoreType): Promise { + if (!(await License.hasModule('abac'))) { + return; + } + const { modifiedCount } = await Rooms.updateMany({ abacAttributes: { $exists: true } }, { $unset: { abacAttributes: '' } }); + if (modifiedCount > 0) { + void Audit.attributeStoreSwitched(from, to, modifiedCount); } } - setPdpStrategy(strategy: 'local' | 'virtru'): void { + setPdpStrategy(strategy: AbacPdpType): void { const previousPdp = this.pdp ? this.pdp.constructor.name : 'none'; switch (strategy) { case 'virtru': - this.pdp = new VirtruPDP({ ...this.virtruPdpConfig }); + this.virtruClient.updateConfig({ ...this.virtruPdpConfig }); + this.pdp = new VirtruPDP(this.virtruClient); this.pdpType = 'virtru'; break; case 'local': @@ -168,7 +263,16 @@ export class AbacService extends ServiceClass implements IAbacService { override async started(): Promise { this.decisionCacheTimeout = await Settings.get('Abac_Cache_Decision_Time_Seconds'); - const pdpType = await Settings.get('ABAC_PDP_Type'); + const [abacEnabled, pdpType, attributeStore] = await Promise.all([ + Settings.get('ABAC_Enabled'), + Settings.get('ABAC_PDP_Type'), + Settings.get('ABAC_Attribute_Store'), + ]); + + this.abacEnabled = abacEnabled; + this.pdpTypeSetting = isAbacPdpType(pdpType) ? pdpType : undefined; + this.attributeStoreSetting = isAbacAttributeStoreType(attributeStore) ? attributeStore : undefined; + if (pdpType !== 'virtru') { this.setPdpStrategy('local'); return; @@ -242,49 +346,28 @@ export class AbacService extends ServiceClass implements IAbacService { } } - async listAbacAttributes(filters?: { key?: string; values?: string; offset?: number; count?: number }): Promise<{ + async listAbacAttributes( + filters?: { key?: string; values?: string; offset?: number; count?: number }, + actor?: AbacActor, + ): Promise<{ attributes: IAbacAttribute[]; offset: number; count: number; total: number; }> { - const query: Document[] = []; - if (filters?.key) { - query.push({ key: new RegExp(escapeRegExp(filters.key), 'i') }); - } - if (filters?.values?.length) { - query.push({ values: new RegExp(escapeRegExp(filters.values), 'i') }); - } - - const offset = filters?.offset ?? 0; - const limit = filters?.count ?? 25; - - const { cursor, totalCount } = AbacAttributes.findPaginated( - { ...(query.length && { $or: query }) }, - { - projection: { key: 1, values: 1 }, - skip: offset, - limit, - }, - ); - - const attributes = await cursor.toArray(); - - return { - attributes, - offset, - count: attributes.length, - total: await totalCount, - }; + return (await this.resolveAttributeStore()).list(actor, filters); } - async listAbacRooms(filters?: { - offset?: number; - count?: number; - filter?: string; - filterType?: 'all' | 'roomName' | 'attribute' | 'value'; - }): Promise<{ - rooms: IRoom[]; + async listAbacRooms( + filters?: { + offset?: number; + count?: number; + filter?: string; + filterType?: 'all' | 'roomName' | 'attribute' | 'value'; + }, + actor?: AbacActor, + ): Promise<{ + rooms: Array; offset: number; count: number; total: number; @@ -332,15 +415,23 @@ export class AbacService extends ServiceClass implements IAbacService { }); const rooms = await cursor.toArray(); + const scoped = actor ? await (await this.resolveAttributeStore()).scopeRoomsPage(rooms, actor) : rooms; return { - rooms, + rooms: scoped, offset, - count: rooms.length, + count: scoped.length, total: await totalCount, }; } + async scopeRoomsForAdmin>( + rooms: T[], + actor: AbacActor, + ): Promise> { + return (await this.resolveAttributeStore()).scopeRoomsPage(rooms, actor); + } + async updateAbacAttributeById(_id: string, update: { key?: string; values?: string[] }, actor: AbacActor): Promise { if (!update.key && !update.values) { return; @@ -432,9 +523,24 @@ export class AbacService extends ServiceClass implements IAbacService { void api.broadcast('watch.rooms', { clientAction: 'updated', room }); } + private async enforceCanModifyRoom(store: IAttributeStore, room: Pick, actor: AbacActor): Promise { + if (await Authorization.hasPermission(actor._id, 'bypass-abac-store-validation')) { + return; + } + await store.assertCanModifyRoom(room, actor); + } + + private async enforceStoreValidation(store: IAttributeStore, attrs: IAbacAttributeDefinition[], actor: AbacActor): Promise { + if (await Authorization.hasPermission(actor._id, 'bypass-abac-store-validation')) { + return; + } + await store.validateAssignable(attrs, actor); + } + async setRoomAbacAttributes(rid: string, attributes: Record, actor: AbacActor): Promise { await this.ensurePdpAvailable(); const room = await getAbacRoom(rid); + const store = await this.resolveAttributeStore(); if (!Object.keys(attributes).length && room.abacAttributes?.length) { await Rooms.unsetAbacAttributesById(rid); @@ -443,9 +549,11 @@ export class AbacService extends ServiceClass implements IAbacService { return; } + await this.enforceCanModifyRoom(store, room, actor); + const normalized = validateAndNormalizeAttributes(attributes); - await ensureAttributeDefinitionsExist(normalized); + await this.enforceStoreValidation(store, normalized, actor); const updated = await Rooms.setAbacAttributesById(rid, normalized); void Audit.objectAttributeChanged({ _id: room._id, name: room.name }, room.abacAttributes || [], normalized, 'updated', actor); @@ -463,6 +571,9 @@ export class AbacService extends ServiceClass implements IAbacService { async updateRoomAbacAttributeValues(rid: string, key: string, values: string[], actor: AbacActor): Promise { await this.ensurePdpAvailable(); const room = await getAbacRoom(rid); + const store = await this.resolveAttributeStore(); + + await this.enforceCanModifyRoom(store, room, actor); const previous: IAbacAttributeDefinition[] = room.abacAttributes || []; @@ -472,7 +583,7 @@ export class AbacService extends ServiceClass implements IAbacService { throw new AbacInvalidAttributeValuesError(); } - await ensureAttributeDefinitionsExist([{ key, values }]); + await this.enforceStoreValidation(store, [{ key, values }], actor); if (isNewKey) { await Rooms.updateSingleAbacAttributeValuesById(rid, key, values); @@ -518,7 +629,11 @@ export class AbacService extends ServiceClass implements IAbacService { async removeRoomAbacAttribute(rid: string, key: string, actor: AbacActor): Promise { await this.ensurePdpAvailable(); + const room = await getAbacRoom(rid); + const store = await this.resolveAttributeStore(); + + await this.enforceCanModifyRoom(store, room, actor); const previous: IAbacAttributeDefinition[] = room.abacAttributes || []; const exists = previous.some((a) => a.key === key); @@ -545,9 +660,13 @@ export class AbacService extends ServiceClass implements IAbacService { async addRoomAbacAttributeByKey(rid: string, key: string, values: string[], actor: AbacActor): Promise { await this.ensurePdpAvailable(); - await ensureAttributeDefinitionsExist([{ key, values }]); const room = await getAbacRoom(rid); + const store = await this.resolveAttributeStore(); + + await this.enforceCanModifyRoom(store, room, actor); + + await this.enforceStoreValidation(store, [{ key, values }], actor); const previous: IAbacAttributeDefinition[] = room.abacAttributes || []; if (previous.some((a) => a.key === key)) { @@ -570,9 +689,13 @@ export class AbacService extends ServiceClass implements IAbacService { async replaceRoomAbacAttributeByKey(rid: string, key: string, values: string[], actor: AbacActor): Promise { await this.ensurePdpAvailable(); - await ensureAttributeDefinitionsExist([{ key, values }]); const room = await getAbacRoom(rid); + const store = await this.resolveAttributeStore(); + + await this.enforceCanModifyRoom(store, room, actor); + + await this.enforceStoreValidation(store, [{ key, values }], actor); const exists = room?.abacAttributes?.find((a) => a.key === key); @@ -688,7 +811,7 @@ export class AbacService extends ServiceClass implements IAbacService { }); } - private pdpType: 'local' | 'virtru' = 'local'; + private pdpType: AbacPdpType = 'local'; private async ensurePdpAvailable(): Promise { if (!(await this.pdp?.isAvailable())) { @@ -837,6 +960,6 @@ export class AbacService extends ServiceClass implements IAbacService { export { LocalPDP, VirtruPDP } from './pdp'; export type { IPolicyDecisionPoint, VirtruPDPConfig } from './pdp'; -export { PdpHealthCheckError, getPdpHealthErrorCode } from './errors'; +export { PdpHealthCheckError, getPdpHealthErrorCode, AbacAttributeStoreExternalError } from './errors'; export default AbacService; diff --git a/ee/packages/abac/src/pdp/VirtruPDP.ts b/ee/packages/abac/src/pdp/VirtruPDP.ts index e6cc185f0f142..671e91642daf7 100644 --- a/ee/packages/abac/src/pdp/VirtruPDP.ts +++ b/ee/packages/abac/src/pdp/VirtruPDP.ts @@ -5,56 +5,22 @@ import pLimit from 'p-limit'; import { OnlyCompliantCanBeAddedToRoomError, PdpHealthCheckError } from '../errors'; import { logger } from '../logger'; -import type { - IEntityIdentifier, - IPolicyDecisionPoint, - IGetDecisionBulkRequest, - IGetDecisionBulkResponse, - IResourceDecision, - ITokenCache, - IVirtruPDPConfig, -} from './types'; +import type { IPolicyDecisionPoint, IGetDecisionBulkRequest, IGetDecisionBulkResponse, IResourceDecision } from './types'; +import { HEALTH_CHECK_TIMEOUT } from '../clients/virtru/VirtruClient'; +import type { VirtruClient } from '../clients/virtru/VirtruClient'; +import { buildEntityIdentifier, buildAttributeFqns, getUserEntityKey } from '../clients/virtru/identity'; const pdpLogger = logger.section('VirtruPDP'); -const HEALTH_CHECK_TIMEOUT = 5000; -const REQUEST_TIMEOUT = 10000; - export class VirtruPDP implements IPolicyDecisionPoint { - private tokenCache: ITokenCache | null = null; - - private config: IVirtruPDPConfig; - - constructor(config: IVirtruPDPConfig) { - this.config = config; - } + private client: VirtruClient; - updateConfig(config: IVirtruPDPConfig): void { - this.config = config; - this.tokenCache = null; + constructor(client: VirtruClient) { + this.client = client; } async isAvailable(): Promise { - try { - const response = await serverFetch(`${this.config.baseUrl}/healthz`, { - method: 'GET', - timeout: HEALTH_CHECK_TIMEOUT, - // SECURITY: This can only be configured by users with enough privileges. It's ok to disable this check here. - ignoreSsrfValidation: true, - }); - - if (!response.ok) { - throw new Error('PDP Health check failed'); - } - - const data = (await response.json()) as { status?: string }; - - pdpLogger.info({ msg: 'Virtru PDP health check response', data }); - return data.status === 'SERVING'; - } catch (err) { - pdpLogger.warn({ msg: 'Virtru PDP is not reachable', err }); - return false; - } + return this.client.isAvailable(); } async getHealthStatus(): Promise { @@ -66,8 +32,7 @@ export class VirtruPDP implements IPolicyDecisionPoint { private async checkIdpConnectivity(): Promise { try { - this.tokenCache = null; - const token = await this.getClientToken(); + const token = await this.client.getClientTokenForHealthCheck(); pdpLogger.info({ msg: 'Virtru PDP health check: IdP connectivity OK' }); return token; } catch (err) { @@ -77,8 +42,9 @@ export class VirtruPDP implements IPolicyDecisionPoint { } private async checkPlatformHealth(): Promise { + const config = this.client.getConfig(); try { - const response = await serverFetch(`${this.config.baseUrl}/healthz`, { + const response = await serverFetch(`${config.baseUrl}/healthz`, { method: 'GET', timeout: HEALTH_CHECK_TIMEOUT, // SECURITY: This can only be configured by users with enough privileges. It's ok to disable this check here. @@ -101,13 +67,14 @@ export class VirtruPDP implements IPolicyDecisionPoint { } private async checkAuthorizedAccess(token: string): Promise { + const config = this.client.getConfig(); try { - const response = await serverFetch(`${this.config.baseUrl}/authorization.v2.AuthorizationService/GetEntitlements`, { + const response = await serverFetch(`${config.baseUrl}/authorization.v2.AuthorizationService/GetEntitlements`, { method: 'POST', timeout: HEALTH_CHECK_TIMEOUT, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ - entityIdentifier: { entityChain: { entities: [{ id: this.config.clientId }] } }, + entityIdentifier: { entityChain: { entities: [{ id: config.clientId }] } }, }), // SECURITY: This can only be configured by users with enough privileges. It's ok to disable this check here. ignoreSsrfValidation: true, @@ -123,63 +90,6 @@ export class VirtruPDP implements IPolicyDecisionPoint { } } - private async getClientToken(): Promise { - if (this.tokenCache && Date.now() < this.tokenCache.expiresAt) { - return this.tokenCache.accessToken; - } - const response = await serverFetch(`${this.config.oidcEndpoint}/protocol/openid-connect/token`, { - method: 'POST', - timeout: REQUEST_TIMEOUT, - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - grant_type: 'client_credentials', - client_id: this.config.clientId, - client_secret: this.config.clientSecret, - }), - // SECURITY: This can only be configured by users with enough privileges. It's ok to disable this check here. - ignoreSsrfValidation: true, - }); - - if (!response.ok) { - throw new Error(`Failed to obtain client token: ${response.status} ${response.statusText}`); - } - - const data = (await response.json()) as { access_token: string; expires_in?: number }; - - const expiresIn = data.expires_in ?? 300; - this.tokenCache = { - accessToken: data.access_token, - // We check for expiry 30 seconds before the actual expiry time for safety. - expiresAt: Date.now() + (expiresIn - 30) * 1000, - }; - - return data.access_token; - } - - private async apiCall(endpoint: string, body: unknown): Promise { - const token = await this.getClientToken(); - - const response = await serverFetch(`${this.config.baseUrl}${endpoint}`, { - method: 'POST', - timeout: REQUEST_TIMEOUT, - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify(body), - // SECURITY: This can only be configured by users with enough privileges. It's ok to disable this check here. - ignoreSsrfValidation: true, - }); - - if (!response.ok) { - const text = await response.text().catch(() => ''); - pdpLogger.error({ msg: 'Virtru PDP API call failed', endpoint, status: response.status, response: text }); - throw new Error('Virtru PDP call failed'); - } - - return response.json() as Promise; - } - private async getDecisionBulk( requests: Array, ): Promise> { @@ -200,7 +110,7 @@ export class VirtruPDP implements IPolicyDecisionPoint { return batch.map(() => undefined); } - const result = await this.apiCall('/authorization.v2.AuthorizationService/GetDecisionBulk', { + const result = await this.client.apiCall('/authorization.v2.AuthorizationService/GetDecisionBulk', { decisionRequests: validBatch, }); @@ -216,33 +126,6 @@ export class VirtruPDP implements IPolicyDecisionPoint { return batchResults.flat(); } - private buildAttributeFqns(attributes: IAbacAttributeDefinition[]): string[] { - if (!this.config.attributeNamespace) { - throw new Error('Attribute namespace is not configured for VirtruPDP'); - } - - return attributes.flatMap((attr) => - attr.values.map((value) => `https://${this.config.attributeNamespace}/attr/${attr.key}/value/${value}`), - ); - } - - private buildEntityIdentifier(entityKey: string): IEntityIdentifier { - if (this.config.defaultEntityKey === 'emailAddress') { - return { emailAddress: entityKey }; - } - - return { id: entityKey }; - } - - private getUserEntityKey(user: Pick): string | undefined { - switch (this.config.defaultEntityKey) { - case 'emailAddress': - return user.emails?.[0]?.address; - case 'oidcIdentifier': - return user.username; // For now, username, we're gonna change this to find the right oidc identifier for the user - } - } - async canAccessObject( room: AtLeast, user: AtLeast, @@ -253,12 +136,13 @@ export class VirtruPDP implements IPolicyDecisionPoint { return { granted: true }; } + const config = this.client.getConfig(); const fullUser = await Users.findOneById(user._id); if (!fullUser) { return { granted: false }; } - const entityKey = this.getUserEntityKey(fullUser); + const entityKey = getUserEntityKey(config.defaultEntityKey, fullUser); if (!entityKey) { pdpLogger.warn({ msg: 'User has no entity key for Virtru PDP evaluation', userId: user._id }); return { granted: false }; @@ -268,14 +152,14 @@ export class VirtruPDP implements IPolicyDecisionPoint { { entityIdentifier: { entityChain: { - entities: [this.buildEntityIdentifier(entityKey)], + entities: [buildEntityIdentifier(config.defaultEntityKey, entityKey)], }, }, action: { name: 'read' }, resources: [ { ephemeralId: room._id, - attributeValues: { fqns: this.buildAttributeFqns(attributes) }, + attributeValues: { fqns: buildAttributeFqns(config.attributeNamespace, attributes) }, }, ], }, @@ -307,13 +191,14 @@ export class VirtruPDP implements IPolicyDecisionPoint { return; } + const config = this.client.getConfig(); const users = await Users.findByUsernames(usernames, { projection: { _id: 1, emails: 1, username: 1 } }).toArray(); - const fqns = this.buildAttributeFqns(attributes); + const fqns = buildAttributeFqns(config.attributeNamespace, attributes); const decisionRequests: IGetDecisionBulkRequest[] = []; for (const user of users) { - const entityKey = this.getUserEntityKey(user); + const entityKey = getUserEntityKey(config.defaultEntityKey, user); if (!entityKey) { throw new OnlyCompliantCanBeAddedToRoomError(); } @@ -321,7 +206,7 @@ export class VirtruPDP implements IPolicyDecisionPoint { decisionRequests.push({ entityIdentifier: { entityChain: { - entities: [this.buildEntityIdentifier(entityKey)], + entities: [buildEntityIdentifier(config.defaultEntityKey, entityKey)], }, }, action: { name: 'read' }, @@ -361,13 +246,14 @@ export class VirtruPDP implements IPolicyDecisionPoint { projection: { _id: 1, emails: 1, username: 1 }, }); + const config = this.client.getConfig(); const nonCompliantUsers: IUser[] = []; const decisionRequests: IGetDecisionBulkRequest[] = []; const requestUserIndex: IUser[] = []; - const fqns = this.buildAttributeFqns(newAttributes); + const fqns = buildAttributeFqns(config.attributeNamespace, newAttributes); for await (const user of users) { - const entityKey = this.getUserEntityKey(user); + const entityKey = getUserEntityKey(config.defaultEntityKey, user); if (!entityKey) { pdpLogger.warn({ msg: 'User has no entity key for Virtru PDP evaluation, treating as non-compliant', userId: user._id }); nonCompliantUsers.push(user); @@ -378,7 +264,7 @@ export class VirtruPDP implements IPolicyDecisionPoint { decisionRequests.push({ entityIdentifier: { entityChain: { - entities: [this.buildEntityIdentifier(entityKey)], + entities: [buildEntityIdentifier(config.defaultEntityKey, entityKey)], }, }, action: { name: 'read' }, @@ -416,10 +302,11 @@ export class VirtruPDP implements IPolicyDecisionPoint { const requestIndex: Array<{ user: Pick; room: AtLeast }> = []; const allRequests: IGetDecisionBulkRequest[] = []; + const config = this.client.getConfig(); const nonCompliant: Array<{ user: Pick; room: IRoom }> = []; for (const { user, rooms } of entries) { - const entityKey = this.getUserEntityKey(user); + const entityKey = getUserEntityKey(config.defaultEntityKey, user); if (!entityKey) { pdpLogger.warn({ msg: 'User has no entity key for Virtru PDP evaluation, treating as non-compliant', userId: user._id }); for (const room of rooms) { @@ -433,14 +320,14 @@ export class VirtruPDP implements IPolicyDecisionPoint { allRequests.push({ entityIdentifier: { entityChain: { - entities: [this.buildEntityIdentifier(entityKey)], + entities: [buildEntityIdentifier(config.defaultEntityKey, entityKey)], }, }, action: { name: 'read' }, resources: [ { ephemeralId: room._id, - attributeValues: { fqns: this.buildAttributeFqns(room.abacAttributes ?? []) }, + attributeValues: { fqns: buildAttributeFqns(config.attributeNamespace, room.abacAttributes ?? []) }, }, ], }); @@ -477,7 +364,8 @@ export class VirtruPDP implements IPolicyDecisionPoint { return []; } - const entityKey = this.getUserEntityKey(user); + const config = this.client.getConfig(); + const entityKey = getUserEntityKey(config.defaultEntityKey, user); if (!entityKey) { pdpLogger.warn({ msg: 'User has no entity key for Virtru PDP evaluation, treating as non-compliant for all ABAC rooms', @@ -489,14 +377,14 @@ export class VirtruPDP implements IPolicyDecisionPoint { const decisionRequests = abacRooms.map((room) => ({ entityIdentifier: { entityChain: { - entities: [this.buildEntityIdentifier(entityKey)], + entities: [buildEntityIdentifier(config.defaultEntityKey, entityKey)], }, }, action: { name: 'read' }, resources: [ { ephemeralId: room._id, - attributeValues: { fqns: this.buildAttributeFqns(room.abacAttributes ?? []) }, + attributeValues: { fqns: buildAttributeFqns(config.attributeNamespace, room.abacAttributes ?? []) }, }, ], })); diff --git a/ee/packages/abac/src/pdp/types.ts b/ee/packages/abac/src/pdp/types.ts index 049d91b9bdcaf..39073421d656c 100644 --- a/ee/packages/abac/src/pdp/types.ts +++ b/ee/packages/abac/src/pdp/types.ts @@ -68,3 +68,21 @@ export interface ITokenCache { accessToken: string; expiresAt: number; } + +export interface IGetEntitlementsRequest { + entityIdentifier: { + entityChain: { + entities: IEntityIdentifier[]; + }; + }; + withComprehensiveHierarchy: boolean; +} + +export interface IEntityEntitlements { + ephemeralId?: string; + actionsPerAttributeValueFqn: Record; +} + +export interface IGetEntitlementsResponse { + entitlements?: IEntityEntitlements[]; +} diff --git a/ee/packages/abac/src/service.spec.ts b/ee/packages/abac/src/service.spec.ts index d07035ea50dd8..8d50dc2abd903 100644 --- a/ee/packages/abac/src/service.spec.ts +++ b/ee/packages/abac/src/service.spec.ts @@ -1,4 +1,34 @@ +import { Audit } from './audit'; +import { VirtruClient } from './clients/virtru/VirtruClient'; import { AbacService } from './index'; +import { LocalAttributeStore, VirtruAttributeStore } from './store'; + +const mockSettingsGet = jest.fn(); +const mockHasModule = jest.fn(); +const mockHasPermission = jest.fn(); + +jest.mock('./store', () => { + const { ensureAttributeDefinitionsExist } = jest.requireActual('./helper'); + return { + LocalAttributeStore: jest.fn().mockImplementation(() => ({ + assertCanModifyRoom: jest.fn().mockResolvedValue(undefined), + validateAssignable: (attrs: any[], _actor: any) => ensureAttributeDefinitionsExist(attrs), + scopeRoomsPage: (rooms: any[]) => Promise.resolve(rooms), + })), + VirtruAttributeStore: jest.fn().mockImplementation(() => ({ + onStoreSelected: jest.fn(), + })), + }; +}); + +jest.mock('./clients/virtru/VirtruClient', () => ({ + VirtruClient: jest.fn().mockImplementation(() => ({ + updateConfig: jest.fn(), + getConfig: jest.fn(() => ({})), + isAvailable: jest.fn(), + apiCall: jest.fn(), + })), +})); const fakeActor = { _id: 'test-user', username: 'testuser', type: 'user' }; @@ -10,12 +40,16 @@ const mockAbacFindOne = jest.fn(); const mockAbacUpdateOne = jest.fn(); const mockAbacDeleteOne = jest.fn(); const mockRoomsIsAbacAttributeInUse = jest.fn(); +const mockRoomsFindPaginated = jest.fn(); const mockSetAbacAttributesById = jest.fn(); const mockAbacFind = jest.fn(); const mockUpdateSingleAbacAttributeValuesById = jest.fn(); const mockUpdateAbacAttributeValuesArrayFilteredById = jest.fn(); const mockRemoveAbacAttributeByRoomIdAndKey = jest.fn(); const mockInsertAbacAttributeIfNotExistsById = jest.fn(); +const mockUnsetAbacAttributesById = jest.fn(); +const mockRoomsUpdateMany = jest.fn(); +const mockSettingsSet = jest.fn(); const mockUsersFind = jest.fn(); const mockUsersUpdateOne = jest.fn(); const mockUsersSetAbacAttributesById = jest.fn(); @@ -28,11 +62,14 @@ jest.mock('@rocket.chat/models', () => ({ findOneByIdAndType: (...args: any[]) => mockFindOneByIdAndType(...args), updateAbacConfigurationById: (...args: any[]) => mockUpdateAbacConfigurationById(...args), isAbacAttributeInUse: (...args: any[]) => mockRoomsIsAbacAttributeInUse(...args), + findPaginated: (...args: any[]) => mockRoomsFindPaginated(...args), setAbacAttributesById: (...args: any[]) => mockSetAbacAttributesById(...args), updateSingleAbacAttributeValuesById: (...args: any[]) => mockUpdateSingleAbacAttributeValuesById(...args), updateAbacAttributeValuesArrayFilteredById: (...args: any[]) => mockUpdateAbacAttributeValuesArrayFilteredById(...args), removeAbacAttributeByRoomIdAndKey: (...args: any[]) => mockRemoveAbacAttributeByRoomIdAndKey(...args), insertAbacAttributeIfNotExistsById: (...args: any[]) => mockInsertAbacAttributeIfNotExistsById(...args), + unsetAbacAttributesById: (...args: any[]) => mockUnsetAbacAttributesById(...args), + updateMany: (...args: any[]) => mockRoomsUpdateMany(...args), }, AbacAttributes: { insertOne: (...args: any[]) => mockAbacInsertOne(...args), @@ -56,6 +93,9 @@ jest.mock('@rocket.chat/models', () => ({ ServerEvents: { createAuditServerEvent: (...args: any[]) => mockCreateAuditServerEvent(...args), }, + Settings: { + updateValueById: (...args: any[]) => mockSettingsSet(...args), + }, })); // Partial mock for @rocket.chat/core-services: keep real MeteorError, override ServiceClass and Room @@ -65,6 +105,8 @@ jest.mock('@rocket.chat/core-services', () => { ...actual, ServiceClass: class { onSettingChanged = jest.fn(); + + onEvent = jest.fn(); }, Room: { removeUserFromRoom: jest.fn(), @@ -72,6 +114,16 @@ jest.mock('@rocket.chat/core-services', () => { api: { broadcast: jest.fn(), }, + Settings: { + get: (...args: any[]) => mockSettingsGet(...args), + set: (...args: any[]) => mockSettingsSet(...args), + }, + License: { + hasModule: (...args: any[]) => mockHasModule(...args), + }, + Authorization: { + hasPermission: (...args: any[]) => mockHasPermission(...args), + }, }; }); @@ -289,102 +341,19 @@ describe('AbacService (unit)', () => { }); describe('listAbacAttributes', () => { - it('returns paginated attributes with defaults (no filters)', async () => { - const docs = [ - { _id: '1', key: 'k1', values: ['a', 'b'] }, - { _id: '2', key: 'k2', values: ['c'] }, - ]; - mockAbacFindPaginated.mockReturnValueOnce({ - cursor: { toArray: async () => docs }, - totalCount: Promise.resolve(docs.length), - }); - - const result = await service.listAbacAttributes(); - expect(mockAbacFindPaginated).toHaveBeenCalledWith({}, { projection: { key: 1, values: 1 }, skip: 0, limit: 25 }); - expect(result).toEqual({ - attributes: docs, - offset: 0, - count: docs.length, - total: docs.length, - }); - }); - - it('filters by key only', async () => { - const docs = [{ _id: '3', key: 'FilterKey', values: ['x'] }]; - mockAbacFindPaginated.mockReturnValueOnce({ - cursor: { toArray: async () => docs }, - totalCount: Promise.resolve(docs.length), - }); - - const result = await service.listAbacAttributes({ key: 'FilterKey' }); - expect(mockAbacFindPaginated).toHaveBeenCalledWith( - { $or: [{ key: /FilterKey/i }] }, - { projection: { key: 1, values: 1 }, skip: 0, limit: 25 }, - ); - expect(result).toEqual({ - attributes: docs, - offset: 0, - count: docs.length, - total: docs.length, - }); - }); - - it('filters by values only with custom pagination', async () => { - const docs = [ - { _id: '4', key: 'alpha', values: ['m', 'n'] }, - { _id: '5', key: 'beta', values: ['n', 'o'] }, - ]; - mockAbacFindPaginated.mockReturnValueOnce({ - cursor: { toArray: async () => docs }, - totalCount: Promise.resolve(10), - }); - - const result = await service.listAbacAttributes({ values: 'n,z', offset: 5, count: 2 }); - expect(mockAbacFindPaginated).toHaveBeenCalledWith( - { $or: [{ values: /n,z/i }] }, - { projection: { key: 1, values: 1 }, skip: 5, limit: 2 }, - ); - expect(result).toEqual({ - attributes: docs, - offset: 5, - count: docs.length, - total: 10, - }); - }); - - it('filters by key and values', async () => { - const docs = [{ _id: '6', key: 'gamma', values: ['p', 'q'] }]; - mockAbacFindPaginated.mockReturnValueOnce({ - cursor: { toArray: async () => docs }, - totalCount: Promise.resolve(docs.length), - }); + const actor = { _id: 'admin-1', username: 'admin', name: 'Admin' }; - const result = await service.listAbacAttributes({ key: 'gamma', values: 'q' }); - expect(mockAbacFindPaginated).toHaveBeenCalledWith( - { $or: [{ key: /gamma/i }, { values: /q/i }] }, - { projection: { key: 1, values: 1 }, skip: 0, limit: 25 }, - ); - expect(result).toEqual({ - attributes: docs, - offset: 0, - count: docs.length, - total: docs.length, - }); - }); + it('delegates to attributeStore.list with the given filters and actor', async () => { + const result = { attributes: [{ _id: 'k', key: 'k', values: ['v'] }], offset: 0, count: 1, total: 1 }; + const fakeStore = { list: jest.fn().mockResolvedValue(result) }; + (service as any).attributeStores.local.store = fakeStore; - it('returns empty when no documents match', async () => { - mockAbacFindPaginated.mockReturnValueOnce({ - cursor: { toArray: async () => [] }, - totalCount: Promise.resolve(0), - }); + const filters = { key: 'k', values: 'v', offset: 0, count: 25 }; + const returned = await service.listAbacAttributes(filters, actor); - const result = await service.listAbacAttributes({ key: 'nope', values: 'none' }); - expect(result).toEqual({ - attributes: [], - offset: 0, - count: 0, - total: 0, - }); + expect(fakeStore.list).toHaveBeenCalledWith(actor, filters); + expect(returned).toBe(result); + expect(mockAbacFindPaginated).not.toHaveBeenCalled(); }); }); @@ -850,6 +819,7 @@ describe('AbacService (unit)', () => { }); it('throws error-invalid-attribute-values when more than 10 values provided', async () => { + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [] }); const values = Array.from({ length: 11 }, (_, i) => `v${i}`); await expect((service as any).replaceRoomAbacAttributeByKey('r1', 'dept', values, fakeActor)).rejects.toThrow( 'error-invalid-attribute-values', @@ -1034,6 +1004,127 @@ describe('AbacService (unit)', () => { }); }); + describe('attribute-store write guard (assertCanModifyRoom + validateAssignable)', () => { + const room = { _id: 'r1', name: 'room', abacAttributes: [{ key: 'dept', values: ['eng'] }] }; + + const makeStore = () => ({ + assertCanModifyRoom: jest.fn().mockResolvedValue(undefined), + validateAssignable: jest.fn().mockResolvedValue(undefined), + }); + + const noMutation = () => { + expect(mockSetAbacAttributesById).not.toHaveBeenCalled(); + expect(mockUnsetAbacAttributesById).not.toHaveBeenCalled(); + expect(mockUpdateSingleAbacAttributeValuesById).not.toHaveBeenCalled(); + expect(mockUpdateAbacAttributeValuesArrayFilteredById).not.toHaveBeenCalled(); + expect(mockInsertAbacAttributeIfNotExistsById).not.toHaveBeenCalled(); + expect(mockCreateAuditServerEvent).not.toHaveBeenCalled(); + }; + + beforeEach(() => { + mockFindOneByIdAndType.mockReset().mockResolvedValue(room); + mockSetAbacAttributesById.mockReset(); + mockUnsetAbacAttributesById.mockReset(); + mockUpdateSingleAbacAttributeValuesById.mockReset(); + mockUpdateAbacAttributeValuesArrayFilteredById + .mockReset() + .mockResolvedValue({ abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }); + mockInsertAbacAttributeIfNotExistsById.mockReset().mockResolvedValue({ + abacAttributes: [ + { key: 'dept', values: ['eng'] }, + { key: 'k2', values: ['v'] }, + ], + }); + mockCreateAuditServerEvent.mockReset(); + mockAbacFind.mockReturnValue({ toArray: async () => [{ key: 'dept', values: ['eng', 'sales'] }] }); + (service as any).onRoomAttributesChanged = jest.fn().mockResolvedValue(undefined); + (service as any).attributeStores.local.store = makeStore(); + }); + + const invoke = (method: string): Promise => { + switch (method) { + case 'setRoomAbacAttributes': + return service.setRoomAbacAttributes('r1', { dept: ['eng', 'sales'] }, fakeActor); + case 'updateRoomAbacAttributeValues': + return service.updateRoomAbacAttributeValues('r1', 'dept', ['eng', 'sales'], fakeActor); + case 'addRoomAbacAttributeByKey': + return service.addRoomAbacAttributeByKey('r1', 'k2', ['v'], fakeActor); + default: + return service.replaceRoomAbacAttributeByKey('r1', 'dept', ['eng', 'sales'], fakeActor); + } + }; + + describe.each([ + ['setRoomAbacAttributes'], + ['updateRoomAbacAttributeValues'], + ['addRoomAbacAttributeByKey'], + ['replaceRoomAbacAttributeByKey'], + ])('%s', (method) => { + it("invokes assertCanModifyRoom with the room's current attributes and propagates a rejection without mutating", async () => { + const store = makeStore(); + store.assertCanModifyRoom.mockRejectedValueOnce(new Error('error-pdp-unavailable')); + (service as any).attributeStores.local.store = store; + + await expect(invoke(method)).rejects.toThrow('error-pdp-unavailable'); + + expect(store.assertCanModifyRoom).toHaveBeenCalledWith( + expect.objectContaining({ _id: 'r1', abacAttributes: [{ key: 'dept', values: ['eng'] }] }), + fakeActor, + ); + expect(store.validateAssignable).not.toHaveBeenCalled(); + noMutation(); + }); + + it('propagates a validateAssignable rejection without mutating', async () => { + const store = makeStore(); + store.validateAssignable.mockRejectedValueOnce(new Error('error-invalid-attribute-values')); + (service as any).attributeStores.local.store = store; + + await expect(invoke(method)).rejects.toThrow('error-invalid-attribute-values'); + + expect(store.assertCanModifyRoom).toHaveBeenCalledTimes(1); + noMutation(); + }); + + it('skips assertCanModifyRoom and validateAssignable when the actor has the bypass permission', async () => { + const store = makeStore(); + store.assertCanModifyRoom.mockRejectedValue(new Error('should-not-be-called')); + store.validateAssignable.mockRejectedValue(new Error('should-not-be-called')); + (service as any).attributeStores.local.store = store; + mockHasPermission.mockResolvedValue(true); + + await expect(invoke(method)).resolves.toBeUndefined(); + + expect(mockHasPermission).toHaveBeenCalledWith(fakeActor._id, 'bypass-abac-store-validation'); + expect(store.assertCanModifyRoom).not.toHaveBeenCalled(); + expect(store.validateAssignable).not.toHaveBeenCalled(); + }); + + it('enforces both store guards when the actor lacks the bypass permission', async () => { + const store = makeStore(); + (service as any).attributeStores.local.store = store; + mockHasPermission.mockResolvedValue(false); + + await expect(invoke(method)).resolves.toBeUndefined(); + + expect(store.assertCanModifyRoom).toHaveBeenCalledTimes(1); + expect(store.validateAssignable).toHaveBeenCalledTimes(1); + }); + }); + + it('skips assertCanModifyRoom on the empty-clear path of setRoomAbacAttributes and still unsets', async () => { + const store = makeStore(); + store.assertCanModifyRoom.mockRejectedValueOnce(new Error('error-pdp-unavailable')); + (service as any).attributeStores.local.store = store; + + await expect(service.setRoomAbacAttributes('r1', {}, fakeActor)).resolves.toBeUndefined(); + + expect(store.assertCanModifyRoom).not.toHaveBeenCalled(); + expect(store.validateAssignable).not.toHaveBeenCalled(); + expect(mockUnsetAbacAttributesById).toHaveBeenCalledWith('r1'); + }); + }); + describe('checkUsernamesMatchAttributes', () => { beforeEach(() => { mockUsersFind.mockReset(); @@ -1139,4 +1230,651 @@ describe('AbacService (unit)', () => { expect(mockCreateAuditServerEvent).not.toHaveBeenCalled(); }); }); + + describe('listAbacRooms', () => { + const actor = { _id: 'admin-1', username: 'admin', name: 'Admin' }; + + const roomA = { _id: 'rA', t: 'p', name: 'alpha', abacAttributes: [{ key: 'dept', values: ['eng'] }] } as any; + const roomB = { _id: 'rB', t: 'p', name: 'beta', abacAttributes: [{ key: 'dept', values: ['sales'] }] } as any; + const roomC = { _id: 'rC', t: 'p', name: 'gamma', abacAttributes: [{ key: 'dept', values: ['hr'] }] } as any; + + const localStore = { scopeRoomsPage: async (rooms: any[]) => rooms }; + + const asVirtru = (svc: AbacService) => { + mockHasModule.mockReturnValue(true); + Object.assign(svc as any, { abacEnabled: true, pdpTypeSetting: 'virtru', attributeStoreSetting: 'virtru' }); + }; + + beforeEach(() => { + mockRoomsFindPaginated.mockReset(); + (service as any).attributeStores.local.store = localStore; + }); + + it('calls Rooms.findPaginated with the base query and pagination, ignoring actor', async () => { + mockRoomsFindPaginated.mockReturnValue({ + cursor: { toArray: async () => [roomA] }, + totalCount: Promise.resolve(1), + }); + + await service.listAbacRooms({ offset: 0, count: 10 }, actor); + + expect(mockRoomsFindPaginated).toHaveBeenCalledWith( + { t: 'p', abacAttributes: { $exists: true, $ne: [] } }, + { skip: 0, limit: 10, sort: { name: 1 } }, + ); + }); + + it('local mode: returns rooms byte-identical to the Mongo page (identity pass-through)', async () => { + mockRoomsFindPaginated.mockReturnValue({ + cursor: { toArray: async () => [roomA, roomB] }, + totalCount: Promise.resolve(2), + }); + + const result = await service.listAbacRooms({ offset: 0, count: 25 }, actor); + + expect(result).toEqual({ rooms: [roomA, roomB], offset: 0, count: 2, total: 2 }); + expect(result.rooms[0]).toBe(roomA); + expect(result.rooms[1]).toBe(roomB); + for (const r of result.rooms) { + expect((r as any).abacAttributesRedacted).toBeUndefined(); + } + }); + + it('virtru mode: permitted rooms are unchanged, denied rooms are redacted', async () => { + asVirtru(service); + const fakeStore = { + scopeRoomsPage: jest + .fn() + .mockImplementation(async (rooms: any[]) => + rooms.map((r) => (r._id === 'rB' ? { ...r, abacAttributes: [], abacAttributesRedacted: true } : r)), + ), + }; + (service as any).attributeStores.virtru.store = fakeStore; + + mockRoomsFindPaginated.mockReturnValue({ + cursor: { toArray: async () => [roomA, roomB, roomC] }, + totalCount: Promise.resolve(10), + }); + + const result = await service.listAbacRooms({ offset: 5, count: 3 }, actor); + + expect(result.rooms).toHaveLength(3); + expect(result.total).toBe(10); + expect(result.offset).toBe(5); + expect(result.count).toBe(3); + + const permitted = result.rooms.find((r) => r._id === 'rA'); + expect(permitted).toEqual(roomA); + expect((permitted as any).abacAttributesRedacted).toBeUndefined(); + + const denied = result.rooms.find((r) => r._id === 'rB'); + expect(denied?.abacAttributes).toEqual([]); + expect((denied as any).abacAttributesRedacted).toBe(true); + + const alsoPermitted = result.rooms.find((r) => r._id === 'rC'); + expect(alsoPermitted).toEqual(roomC); + }); + + it('virtru mode: order of rooms is preserved after scoping', async () => { + asVirtru(service); + const ordered = [roomC, roomA, roomB]; + const fakeStore = { + scopeRoomsPage: jest + .fn() + .mockResolvedValue(ordered.map((r) => (r._id === 'rA' ? { ...r, abacAttributes: [], abacAttributesRedacted: true } : r))), + }; + (service as any).attributeStores.virtru.store = fakeStore; + + mockRoomsFindPaginated.mockReturnValue({ + cursor: { toArray: async () => ordered }, + totalCount: Promise.resolve(3), + }); + + const result = await service.listAbacRooms({ offset: 0, count: 25 }, actor); + + expect(result.rooms.map((r) => r._id)).toEqual(['rC', 'rA', 'rB']); + }); + + it('virtru mode: total and offset are not changed by scoping', async () => { + asVirtru(service); + const fakeStore = { + scopeRoomsPage: jest.fn().mockResolvedValue([{ ...roomA, abacAttributes: [], abacAttributesRedacted: true }]), + }; + (service as any).attributeStores.virtru.store = fakeStore; + + mockRoomsFindPaginated.mockReturnValue({ + cursor: { toArray: async () => [roomA] }, + totalCount: Promise.resolve(99), + }); + + const result = await service.listAbacRooms({ offset: 20, count: 1 }, actor); + + expect(result.total).toBe(99); + expect(result.offset).toBe(20); + expect(result.count).toBe(1); + }); + }); + + describe('scopeRoomsForAdmin', () => { + const actor = { _id: 'admin-1', username: 'admin', name: 'Admin' }; + + const roomA = { _id: 'rA', abacAttributes: [{ key: 'dept', values: ['eng'] }] } as any; + const roomB = { _id: 'rB', abacAttributes: [{ key: 'dept', values: ['sales'] }] } as any; + + it('always delegates to attributeStore.scopeRoomsPage (local store is a no-op pass-through)', async () => { + const fakeStore = { scopeRoomsPage: jest.fn().mockImplementation(async (rooms: any[]) => rooms) }; + (service as any).attributeStores.local.store = fakeStore; + + const input = [roomA, roomB]; + const result = await service.scopeRoomsForAdmin(input, actor); + + expect(fakeStore.scopeRoomsPage).toHaveBeenCalledWith(input, actor); + expect(result).toEqual([roomA, roomB]); + }); + }); + + describe('attribute store selection', () => { + const buildSettings = (overrides: Record) => + ({ + Abac_Cache_Decision_Time_Seconds: 60, + ABAC_Enabled: true, + ABAC_PDP_Type: 'virtru', + ABAC_Attribute_Store: 'virtru', + ABAC_Virtru_Base_URL: '', + ABAC_Virtru_Client_ID: '', + ABAC_Virtru_Client_Secret: '', + ABAC_Virtru_OIDC_Endpoint: '', + ABAC_Virtru_Default_Entity_Key: 'emailAddress', + ABAC_Virtru_Attribute_Namespace: 'example.com', + ...overrides, + }) as Record; + + const drive = async (settings: Record) => { + mockSettingsGet.mockImplementation(async (key: string) => settings[key]); + const svc = new AbacService(); + await svc.started(); + return svc; + }; + + beforeEach(() => { + (LocalAttributeStore as jest.Mock).mockClear(); + (VirtruAttributeStore as jest.Mock).mockClear(); + (VirtruClient as jest.Mock).mockClear(); + mockHasModule.mockReset(); + mockSettingsGet.mockReset(); + }); + + it('selects the virtru store when license + all three settings are virtru/enabled', async () => { + mockHasModule.mockReturnValue(true); + const svc = await drive(buildSettings({})); + expect(await svc.isExternalAttributeStore()).toBe(true); + }); + + it('falls back to the local store when the license module is absent', async () => { + mockHasModule.mockReturnValue(false); + const svc = await drive(buildSettings({})); + expect(await svc.isExternalAttributeStore()).toBe(false); + }); + + it('falls back to the local store when ABAC_Enabled is false', async () => { + mockHasModule.mockReturnValue(true); + const svc = await drive(buildSettings({ ABAC_Enabled: false })); + expect(await svc.isExternalAttributeStore()).toBe(false); + }); + + it('falls back to the local store when ABAC_PDP_Type is not virtru', async () => { + mockHasModule.mockReturnValue(true); + const svc = await drive(buildSettings({ ABAC_PDP_Type: 'local' })); + expect(await svc.isExternalAttributeStore()).toBe(false); + }); + + it('falls back to the local store when ABAC_Attribute_Store is not virtru', async () => { + mockHasModule.mockReturnValue(true); + const svc = await drive(buildSettings({ ABAC_Attribute_Store: 'local' })); + expect(await svc.isExternalAttributeStore()).toBe(false); + }); + + it('reuses the same long-lived VirtruClient across local->virtru->local PDP flips', async () => { + mockHasModule.mockReturnValue(true); + mockSettingsGet.mockImplementation(async (key: string) => buildSettings({ ABAC_PDP_Type: 'local' })[key]); + const svc = new AbacService(); + await svc.started(); + + expect(VirtruClient).toHaveBeenCalledTimes(1); + + svc.setPdpStrategy('virtru'); + svc.setPdpStrategy('local'); + svc.setPdpStrategy('virtru'); + + expect(VirtruClient).toHaveBeenCalledTimes(1); + }); + }); + + describe('attribute store transition detection', () => { + const buildSettings = (overrides: Record) => + ({ + Abac_Cache_Decision_Time_Seconds: 60, + ABAC_Enabled: true, + ABAC_PDP_Type: 'virtru', + ABAC_Attribute_Store: 'virtru', + ABAC_Virtru_Base_URL: '', + ABAC_Virtru_Client_ID: '', + ABAC_Virtru_Client_Secret: '', + ABAC_Virtru_OIDC_Endpoint: '', + ABAC_Virtru_Default_Entity_Key: 'emailAddress', + ABAC_Virtru_Attribute_Namespace: 'example.com', + ...overrides, + }) as Record; + + let transitionSpy: jest.SpyInstance; + + beforeEach(() => { + (LocalAttributeStore as jest.Mock).mockClear(); + (VirtruAttributeStore as jest.Mock).mockClear(); + (VirtruClient as jest.Mock).mockClear(); + mockHasModule.mockReset(); + mockSettingsGet.mockReset(); + transitionSpy = jest.spyOn(AbacService.prototype as any, 'onAttributeStoreTransition').mockResolvedValue(undefined); + }); + + afterEach(() => { + transitionSpy.mockRestore(); + }); + + const bootWith = async (settings: Record) => { + mockSettingsGet.mockImplementation(async (key: string) => settings[key]); + const svc = new AbacService(); + await svc.started(); + return svc; + }; + + type SettingCb = (arg: { setting: { value: unknown } }) => void | Promise; + const fireSettingChanged = async (svc: AbacService, settingName: string, value: unknown): Promise => { + const { calls }: { calls: [string, SettingCb][] } = (svc as any).onSettingChanged.mock; + const entry = calls.find(([name]) => name === settingName); + if (!entry) throw new Error(`No listener registered for ${settingName}`); + await entry[1]({ setting: { value } }); + }; + + it('does not treat boot into virtru as a transition', async () => { + mockHasModule.mockReturnValue(true); + await bootWith(buildSettings({})); + expect(transitionSpy).not.toHaveBeenCalled(); + }); + + it('does not fire on a steady-state re-evaluation via the real ABAC_Attribute_Store listener (same value)', async () => { + mockHasModule.mockReturnValue(true); + const svc = await bootWith(buildSettings({})); + await fireSettingChanged(svc, 'ABAC_Attribute_Store', 'virtru'); + expect(transitionSpy).not.toHaveBeenCalled(); + }); + + it('fires exactly once when ABAC_Attribute_Store flips local->virtru via the real listener', async () => { + mockHasModule.mockReturnValue(true); + const svc = await bootWith(buildSettings({ ABAC_Attribute_Store: 'local' })); + expect(transitionSpy).not.toHaveBeenCalled(); + + await fireSettingChanged(svc, 'ABAC_Attribute_Store', 'virtru'); + + expect(transitionSpy).toHaveBeenCalledTimes(1); + expect(transitionSpy).toHaveBeenCalledWith('local', 'virtru'); + }); + + it('fires exactly once when ABAC_Attribute_Store flips virtru->local via the real listener', async () => { + mockHasModule.mockReturnValue(true); + const svc = await bootWith(buildSettings({})); + expect(transitionSpy).not.toHaveBeenCalled(); + + await fireSettingChanged(svc, 'ABAC_Attribute_Store', 'local'); + + expect(transitionSpy).toHaveBeenCalledTimes(1); + expect(transitionSpy).toHaveBeenCalledWith('virtru', 'local'); + }); + + it('does NOT fire when ABAC_Enabled flips false->true via the real listener (Store setting does not change)', async () => { + mockHasModule.mockReturnValue(true); + const svc = await bootWith(buildSettings({ ABAC_Enabled: false })); + expect(transitionSpy).not.toHaveBeenCalled(); + + await fireSettingChanged(svc, 'ABAC_Enabled', true); + + expect(transitionSpy).not.toHaveBeenCalled(); + }); + + it('fires local->virtru when ABAC_PDP_Type flips local->virtru while Store=virtru (effective store transitions)', async () => { + mockHasModule.mockReturnValue(true); + const svc = await bootWith(buildSettings({ ABAC_PDP_Type: 'local' })); + expect(transitionSpy).not.toHaveBeenCalled(); + + await fireSettingChanged(svc, 'ABAC_PDP_Type', 'virtru'); + + expect(transitionSpy).toHaveBeenCalledTimes(1); + expect(transitionSpy).toHaveBeenCalledWith('local', 'virtru'); + }); + + it('does NOT fire when ABAC_PDP_Type flips local->virtru while Store=local (effective store stays local)', async () => { + mockHasModule.mockReturnValue(true); + const svc = await bootWith(buildSettings({ ABAC_PDP_Type: 'local', ABAC_Attribute_Store: 'local' })); + expect(transitionSpy).not.toHaveBeenCalled(); + + await fireSettingChanged(svc, 'ABAC_PDP_Type', 'virtru'); + + expect(transitionSpy).not.toHaveBeenCalled(); + }); + }); + + describe('attribute-store transition wipe (onAttributeStoreTransition)', () => { + const buildSettings = (overrides: Record) => + ({ + Abac_Cache_Decision_Time_Seconds: 60, + ABAC_Enabled: true, + ABAC_PDP_Type: 'virtru', + ABAC_Attribute_Store: 'virtru', + ABAC_Virtru_Base_URL: '', + ABAC_Virtru_Client_ID: '', + ABAC_Virtru_Client_Secret: '', + ABAC_Virtru_OIDC_Endpoint: '', + ABAC_Virtru_Default_Entity_Key: 'emailAddress', + ABAC_Virtru_Attribute_Namespace: 'example.com', + ...overrides, + }) as Record; + + let auditSpy: jest.SpyInstance; + let evictionSpy: jest.SpyInstance; + let pdpRoomAttrsSpy: jest.Mock; + + beforeEach(() => { + (LocalAttributeStore as jest.Mock).mockClear(); + (VirtruAttributeStore as jest.Mock).mockClear(); + (VirtruClient as jest.Mock).mockClear(); + mockHasModule.mockReset(); + mockSettingsGet.mockReset(); + mockRoomsUpdateMany.mockReset(); + mockCreateAuditServerEvent.mockReset(); + auditSpy = jest.spyOn(Audit, 'attributeStoreSwitched').mockResolvedValue(undefined); + evictionSpy = jest.spyOn(AbacService.prototype as any, 'onRoomAttributesChanged').mockResolvedValue(undefined); + pdpRoomAttrsSpy = jest.fn(); + }); + + afterEach(() => { + auditSpy.mockRestore(); + evictionSpy.mockRestore(); + }); + + const bootWith = async (settings: Record) => { + mockSettingsGet.mockImplementation(async (key: string) => settings[key]); + const svc = new AbacService(); + (svc as any).pdp = { onRoomAttributesChanged: pdpRoomAttrsSpy, canAccessObject: jest.fn(), isAvailable: jest.fn() }; + await svc.started(); + return svc; + }; + + type SettingCb = (arg: { setting: { value: unknown } }) => void | Promise; + const fireSettingChanged = async (svc: AbacService, settingName: string, value: unknown): Promise => { + const { calls }: { calls: [string, SettingCb][] } = (svc as any).onSettingChanged.mock; + const entry = calls.find(([name]) => name === settingName); + if (!entry) throw new Error(`No listener registered for ${settingName}`); + await entry[1]({ setting: { value } }); + }; + + const setVirtruMode = (svc: AbacService) => { + (svc as any).lastEffectiveStore = 'virtru'; + (svc as any).attributeStoreSetting = 'virtru'; + (svc as any).pdpTypeSetting = 'virtru'; + (svc as any).abacEnabled = true; + }; + + it('wipes and audits (local->virtru, N) when ABAC_Attribute_Store flips local->virtru via the real listener', async () => { + mockHasModule.mockReturnValue(true); + mockRoomsUpdateMany.mockResolvedValue({ modifiedCount: 7 }); + const svc = await bootWith(buildSettings({ ABAC_Attribute_Store: 'local' })); + + await fireSettingChanged(svc, 'ABAC_Attribute_Store', 'virtru'); + await new Promise((r) => setImmediate(r)); + + expect(mockRoomsUpdateMany).toHaveBeenCalledTimes(1); + expect(mockRoomsUpdateMany).toHaveBeenCalledWith({ abacAttributes: { $exists: true } }, { $unset: { abacAttributes: '' } }); + expect(auditSpy).toHaveBeenCalledTimes(1); + expect(auditSpy).toHaveBeenCalledWith('local', 'virtru', 7); + }); + + it('wipes and audits (virtru->local, N) when ABAC_Attribute_Store explicitly set to local while other conditions stay virtru', async () => { + mockHasModule.mockReturnValue(true); + mockRoomsUpdateMany.mockResolvedValue({ modifiedCount: 5 }); + const svc = await bootWith(buildSettings({})); + setVirtruMode(svc); + + await fireSettingChanged(svc, 'ABAC_Attribute_Store', 'local'); + await new Promise((r) => setImmediate(r)); + + expect(mockRoomsUpdateMany).toHaveBeenCalledTimes(1); + expect(mockRoomsUpdateMany).toHaveBeenCalledWith({ abacAttributes: { $exists: true } }, { $unset: { abacAttributes: '' } }); + expect(auditSpy).toHaveBeenCalledTimes(1); + expect(auditSpy).toHaveBeenCalledWith('virtru', 'local', 5); + }); + + it('skips updateMany and audit when the abac license is absent at Store-setting change time', async () => { + mockHasModule.mockReturnValue(true); + const svc = await bootWith(buildSettings({ ABAC_Attribute_Store: 'local' })); + + mockHasModule.mockReturnValue(false); + await fireSettingChanged(svc, 'ABAC_Attribute_Store', 'virtru'); + await new Promise((r) => setImmediate(r)); + + expect(mockRoomsUpdateMany).not.toHaveBeenCalled(); + expect(auditSpy).not.toHaveBeenCalled(); + }); + + it('does not block the settings-save: the listener returns before updateMany resolves, audit fires after', async () => { + mockHasModule.mockReturnValue(true); + let resolveWipe: (value: { modifiedCount: number }) => void = () => undefined; + mockRoomsUpdateMany.mockReturnValue( + new Promise<{ modifiedCount: number }>((resolve) => { + resolveWipe = resolve; + }), + ); + const svc = await bootWith(buildSettings({ ABAC_Attribute_Store: 'local' })); + + const { calls }: { calls: [string, (arg: { setting: { value: unknown } }) => void][] } = (svc as any).onSettingChanged.mock; + const entry = calls.find(([name]) => name === 'ABAC_Attribute_Store'); + if (!entry) throw new Error('No listener registered for ABAC_Attribute_Store'); + + entry[1]({ setting: { value: 'virtru' } }); + await new Promise((r) => setImmediate(r)); + + expect(mockRoomsUpdateMany).toHaveBeenCalledTimes(1); + expect(auditSpy).not.toHaveBeenCalled(); + + resolveWipe({ modifiedCount: 4 }); + await new Promise((r) => setImmediate(r)); + + expect(auditSpy).toHaveBeenCalledTimes(1); + expect(auditSpy).toHaveBeenCalledWith('local', 'virtru', 4); + }); + + it('never triggers eviction / PDP / per-room audit during the local->virtru wipe', async () => { + mockHasModule.mockReturnValue(true); + mockRoomsUpdateMany.mockResolvedValue({ modifiedCount: 12 }); + const svc = await bootWith(buildSettings({ ABAC_Attribute_Store: 'local' })); + + await fireSettingChanged(svc, 'ABAC_Attribute_Store', 'virtru'); + await new Promise((r) => setImmediate(r)); + + expect(evictionSpy).not.toHaveBeenCalled(); + expect(pdpRoomAttrsSpy).not.toHaveBeenCalled(); + expect(mockCreateAuditServerEvent).not.toHaveBeenCalled(); + }); + + it('does not emit an audit when updateMany reports modifiedCount: 0 (loser node in multi-node fan-out)', async () => { + mockHasModule.mockReturnValue(true); + mockRoomsUpdateMany.mockResolvedValue({ modifiedCount: 0 }); + const svc = await bootWith(buildSettings({ ABAC_Attribute_Store: 'local' })); + + await fireSettingChanged(svc, 'ABAC_Attribute_Store', 'virtru'); + await new Promise((r) => setImmediate(r)); + + expect(mockRoomsUpdateMany).toHaveBeenCalledTimes(1); + expect(auditSpy).not.toHaveBeenCalled(); + }); + + it('does not run the wipe on boot or on unrelated setting changes', async () => { + mockHasModule.mockReturnValue(true); + const svc = await bootWith(buildSettings({})); + await new Promise((r) => setImmediate(r)); + expect(mockRoomsUpdateMany).not.toHaveBeenCalled(); + expect(auditSpy).not.toHaveBeenCalled(); + + await fireSettingChanged(svc, 'ABAC_Virtru_Attribute_Namespace', 'other.example'); + await new Promise((r) => setImmediate(r)); + expect(mockRoomsUpdateMany).not.toHaveBeenCalled(); + expect(auditSpy).not.toHaveBeenCalled(); + }); + + it('wipes virtru->local when ABAC_PDP_Type flips to local while Store=virtru (effective store transitions)', async () => { + mockHasModule.mockReturnValue(true); + mockRoomsUpdateMany.mockResolvedValue({ modifiedCount: 4 }); + const svc = await bootWith(buildSettings({})); + setVirtruMode(svc); + + await fireSettingChanged(svc, 'ABAC_PDP_Type', 'local'); + await new Promise((r) => setImmediate(r)); + + expect(mockRoomsUpdateMany).toHaveBeenCalledTimes(1); + expect(mockRoomsUpdateMany).toHaveBeenCalledWith({ abacAttributes: { $exists: true } }, { $unset: { abacAttributes: '' } }); + expect(auditSpy).toHaveBeenCalledTimes(1); + expect(auditSpy).toHaveBeenCalledWith('virtru', 'local', 4); + }); + + it('does NOT wipe when ABAC_PDP_Type flips to local while Store=local (effective store stays local)', async () => { + mockHasModule.mockReturnValue(true); + mockRoomsUpdateMany.mockResolvedValue({ modifiedCount: 9 }); + const svc = await bootWith(buildSettings({ ABAC_Attribute_Store: 'local' })); + + await fireSettingChanged(svc, 'ABAC_PDP_Type', 'local'); + await new Promise((r) => setImmediate(r)); + + expect(mockRoomsUpdateMany).not.toHaveBeenCalled(); + expect(auditSpy).not.toHaveBeenCalled(); + }); + + it('does NOT wipe or audit when ABAC_Enabled changes while Store stays virtru', async () => { + mockHasModule.mockReturnValue(true); + const svc = await bootWith(buildSettings({})); + setVirtruMode(svc); + + await fireSettingChanged(svc, 'ABAC_Enabled', false); + await new Promise((r) => setImmediate(r)); + + expect(mockRoomsUpdateMany).not.toHaveBeenCalled(); + expect(auditSpy).not.toHaveBeenCalled(); + }); + + it('does NOT wipe when ABAC_Attribute_Store flips local->virtru while ABAC_Enabled is false (effective store stays local)', async () => { + mockHasModule.mockReturnValue(true); + mockRoomsUpdateMany.mockResolvedValue({ modifiedCount: 9 }); + const svc = await bootWith(buildSettings({ ABAC_Enabled: false, ABAC_Attribute_Store: 'local' })); + + await fireSettingChanged(svc, 'ABAC_Attribute_Store', 'virtru'); + await new Promise((r) => setImmediate(r)); + + expect(mockRoomsUpdateMany).not.toHaveBeenCalled(); + expect(auditSpy).not.toHaveBeenCalled(); + }); + + it('does NOT wipe when ABAC_Attribute_Store flips local->virtru while ABAC_PDP_Type is local (effective store stays local)', async () => { + mockHasModule.mockReturnValue(true); + mockRoomsUpdateMany.mockResolvedValue({ modifiedCount: 9 }); + const svc = await bootWith(buildSettings({ ABAC_PDP_Type: 'local', ABAC_Attribute_Store: 'local' })); + + await fireSettingChanged(svc, 'ABAC_Attribute_Store', 'virtru'); + await new Promise((r) => setImmediate(r)); + + expect(mockRoomsUpdateMany).not.toHaveBeenCalled(); + expect(auditSpy).not.toHaveBeenCalled(); + }); + + it('does NOT wipe when ABAC_Attribute_Store flips virtru->local while the virtru store was never effective (PDP=local)', async () => { + mockHasModule.mockReturnValue(true); + mockRoomsUpdateMany.mockResolvedValue({ modifiedCount: 9 }); + const svc = await bootWith(buildSettings({ ABAC_PDP_Type: 'local', ABAC_Attribute_Store: 'virtru' })); + + await fireSettingChanged(svc, 'ABAC_Attribute_Store', 'local'); + await new Promise((r) => setImmediate(r)); + + expect(mockRoomsUpdateMany).not.toHaveBeenCalled(); + expect(auditSpy).not.toHaveBeenCalled(); + }); + }); + + describe('ABAC_PDP_Type→local cascade to ABAC_Attribute_Store', () => { + const buildSettings = (overrides: Record) => + ({ + Abac_Cache_Decision_Time_Seconds: 60, + ABAC_Enabled: true, + ABAC_PDP_Type: 'virtru', + ABAC_Attribute_Store: 'virtru', + ABAC_Virtru_Base_URL: '', + ABAC_Virtru_Client_ID: '', + ABAC_Virtru_Client_Secret: '', + ABAC_Virtru_OIDC_Endpoint: '', + ABAC_Virtru_Default_Entity_Key: 'emailAddress', + ABAC_Virtru_Attribute_Namespace: 'example.com', + ...overrides, + }) as Record; + + const bootWith = async (settings: Record) => { + mockSettingsGet.mockImplementation(async (key: string) => settings[key]); + const svc = new AbacService(); + await svc.started(); + return svc; + }; + + type SettingCb = (arg: { setting: { value: unknown } }) => void | Promise; + const fireSettingChanged = async (svc: AbacService, settingName: string, value: unknown): Promise => { + const { calls }: { calls: [string, SettingCb][] } = (svc as any).onSettingChanged.mock; + const entry = calls.find(([name]) => name === settingName); + if (!entry) throw new Error(`No listener registered for ${settingName}`); + await entry[1]({ setting: { value } }); + }; + + beforeEach(() => { + mockSettingsGet.mockReset(); + mockSettingsSet.mockReset().mockResolvedValue({ modifiedCount: 1 }); + }); + + it('writes ABAC_Attribute_Store=local when PDP changes to local and Store was virtru', async () => { + const svc = await bootWith(buildSettings({})); + + await fireSettingChanged(svc, 'ABAC_PDP_Type', 'local'); + + expect(mockSettingsSet).toHaveBeenCalledTimes(1); + expect(mockSettingsSet).toHaveBeenCalledWith('ABAC_Attribute_Store', 'local'); + }); + + it('does NOT write ABAC_Attribute_Store when PDP changes to local and Store is already local (idempotent)', async () => { + const svc = await bootWith(buildSettings({ ABAC_Attribute_Store: 'local' })); + + await fireSettingChanged(svc, 'ABAC_PDP_Type', 'local'); + + expect(mockSettingsSet).not.toHaveBeenCalled(); + }); + + it('does NOT write ABAC_Attribute_Store when PDP changes to virtru (one-way cascade only)', async () => { + const svc = await bootWith(buildSettings({ ABAC_PDP_Type: 'local', ABAC_Attribute_Store: 'local' })); + + await fireSettingChanged(svc, 'ABAC_PDP_Type', 'virtru'); + + expect(mockSettingsSet).not.toHaveBeenCalled(); + }); + + it('resolves normally when the settings write fails', async () => { + const svc = await bootWith(buildSettings({})); + mockSettingsSet.mockRejectedValueOnce(new Error('db-write-failure')); + const pdpStrategySpy = jest.spyOn(svc as any, 'setPdpStrategy'); + + await expect(fireSettingChanged(svc, 'ABAC_PDP_Type', 'local')).resolves.toBeUndefined(); + + expect(pdpStrategySpy).toHaveBeenCalledWith('local'); + }); + }); }); diff --git a/ee/packages/abac/src/store/LocalAttributeStore.spec.ts b/ee/packages/abac/src/store/LocalAttributeStore.spec.ts new file mode 100644 index 0000000000000..f7eaebe9b6cbd --- /dev/null +++ b/ee/packages/abac/src/store/LocalAttributeStore.spec.ts @@ -0,0 +1,54 @@ +import { LocalAttributeStore } from './LocalAttributeStore'; + +const ensureMock = jest.fn(); +jest.mock('../helper', () => ({ ensureAttributeDefinitionsExist: (...a: unknown[]) => ensureMock(...a) })); +const findPaginated = jest.fn(); +jest.mock('@rocket.chat/models', () => ({ AbacAttributes: { findPaginated: (...a: unknown[]) => findPaginated(...a) } })); + +const actor = { _id: 'u', username: 'bob', name: 'Bob' }; + +beforeEach(() => { + ensureMock.mockReset(); + findPaginated.mockReset(); +}); + +describe('LocalAttributeStore', () => { + it('validateAssignable delegates to ensureAttributeDefinitionsExist', async () => { + const attrs = [{ key: 'k', values: ['v'] }]; + await new LocalAttributeStore().validateAssignable(attrs, actor); + expect(ensureMock).toHaveBeenCalledWith(attrs); + }); + + it('scopeRoomsPage is identity pass-through (no flag)', async () => { + const rooms = [{ _id: 'r1', abacAttributes: [{ key: 'k', values: ['v'] }] }]; + const out = await new LocalAttributeStore().scopeRoomsPage(rooms, actor); + expect(out).toEqual(rooms); + expect(out[0]).not.toHaveProperty('abacAttributesRedacted'); + }); + + it('assertCanModifyRoom is a no-op', async () => { + await expect(new LocalAttributeStore().assertCanModifyRoom({ _id: 'r', abacAttributes: [] }, actor)).resolves.toBeUndefined(); + }); + + it('entitlementsOf returns the everything sentinel (empty map)', async () => { + const e = await new LocalAttributeStore().entitlementsOf(actor); + expect(e.size).toBe(0); + }); + + it('list queries AbacAttributes paginated (no filters)', async () => { + const docs = [{ _id: 'id1', key: 'k', values: ['v'] }]; + findPaginated.mockReturnValue({ cursor: { toArray: async () => docs }, totalCount: Promise.resolve(1) }); + const r = await new LocalAttributeStore().list(actor, { offset: 0, count: 25 }); + expect(findPaginated).toHaveBeenCalledWith({}, { projection: { key: 1, values: 1 }, skip: 0, limit: 25 }); + expect(r).toEqual({ attributes: docs, offset: 0, count: 1, total: 1 }); + }); + + it('list builds $or query when key and values filters provided', async () => { + findPaginated.mockReturnValue({ cursor: { toArray: async () => [] }, totalCount: Promise.resolve(0) }); + await new LocalAttributeStore().list(actor, { key: 'foo', values: 'bar', offset: 5, count: 10 }); + expect(findPaginated).toHaveBeenCalledWith( + { $or: [{ key: /foo/i }, { values: /bar/i }] }, + { projection: { key: 1, values: 1 }, skip: 5, limit: 10 }, + ); + }); +}); diff --git a/ee/packages/abac/src/store/LocalAttributeStore.ts b/ee/packages/abac/src/store/LocalAttributeStore.ts new file mode 100644 index 0000000000000..269bc159db014 --- /dev/null +++ b/ee/packages/abac/src/store/LocalAttributeStore.ts @@ -0,0 +1,58 @@ +import type { AbacActor } from '@rocket.chat/core-services'; +import type { IAbacAttributeDefinition, IRoom, IRoomAbacRedaction } from '@rocket.chat/core-typings'; +import { AbacAttributes } from '@rocket.chat/models'; +import { escapeRegExp } from '@rocket.chat/string-helpers'; +import type { Document } from 'mongodb'; + +import { ensureAttributeDefinitionsExist } from '../helper'; +import type { AttributeEntitlements, IAttributeStore, ListAttributesOptions, ListAttributesResult } from './types'; + +export class LocalAttributeStore implements IAttributeStore { + async list(_actor: AbacActor | undefined, opts?: ListAttributesOptions): Promise { + const offset = opts?.offset ?? 0; + const limit = opts?.count ?? 25; + + const clauses: Document[] = []; + if (opts?.key) { + clauses.push({ key: new RegExp(escapeRegExp(opts.key), 'i') }); + } + if (opts?.values?.length) { + clauses.push({ values: new RegExp(escapeRegExp(opts.values), 'i') }); + } + + const { cursor, totalCount } = AbacAttributes.findPaginated( + { ...(clauses.length && { $or: clauses }) }, + { + projection: { key: 1, values: 1 }, + skip: offset, + limit, + }, + ); + const attributes = await cursor.toArray(); + return { + attributes, + offset, + count: attributes.length, + total: await totalCount, + }; + } + + async validateAssignable(attrs: IAbacAttributeDefinition[], _actor: AbacActor): Promise { + await ensureAttributeDefinitionsExist(attrs); + } + + async entitlementsOf(_actor: AbacActor): Promise { + return new Map(); + } + + async scopeRoomsPage>( + rooms: T[], + _actor: AbacActor, + ): Promise> { + return rooms; + } + + async assertCanModifyRoom(_room: Pick, _actor: AbacActor): Promise { + // nop + } +} diff --git a/ee/packages/abac/src/store/VirtruAttributeStore.spec.ts b/ee/packages/abac/src/store/VirtruAttributeStore.spec.ts new file mode 100644 index 0000000000000..54736cb5767e5 --- /dev/null +++ b/ee/packages/abac/src/store/VirtruAttributeStore.spec.ts @@ -0,0 +1,278 @@ +import { VirtruAttributeStore } from './VirtruAttributeStore'; + +const usersFindOneById = jest.fn(); +jest.mock('@rocket.chat/models', () => ({ Users: { findOneById: (...a: unknown[]) => usersFindOneById(...a) } })); + +const cfg = { + baseUrl: 'http://pdp', + clientId: 'c', + clientSecret: 's', + oidcEndpoint: 'http://o', + defaultEntityKey: 'emailAddress' as const, + attributeNamespace: 'example.com', +}; +const actor = { _id: 'u1', username: 'bob', name: 'Bob' }; + +const mkClient = (over: Partial> = {}) => + ({ + isAvailable: over.isAvailable ?? jest.fn().mockResolvedValue(true), + apiCall: over.apiCall ?? jest.fn(), + getConfig: over.getConfig ?? jest.fn().mockReturnValue(cfg), + }) as any; + +beforeEach(() => usersFindOneById.mockReset().mockResolvedValue({ _id: 'u1', emails: [{ address: 'bob@x.com' }], username: 'bob' })); + +describe('VirtruAttributeStore.entitlementsOf / list', () => { + it('parses FQN map keys into grouped {key,values}', async () => { + const apiCall = jest.fn().mockResolvedValue({ + entitlements: [ + { + actionsPerAttributeValueFqn: { + 'https://example.com/attr/clearance/value/secret': {}, + 'https://example.com/attr/clearance/value/topsecret': {}, + 'https://example.com/attr/team/value/blue': {}, + }, + }, + ], + }); + const store = new VirtruAttributeStore(mkClient({ apiCall })); + const r = await store.list(actor); + expect(r.attributes).toEqual( + expect.arrayContaining([ + { _id: 'clearance', key: 'clearance', values: expect.arrayContaining(['secret', 'topsecret']) }, + { _id: 'team', key: 'team', values: ['blue'] }, + ]), + ); + expect(r).toMatchObject({ offset: 0, total: 2 }); + }); + + it('malformed FQNs from PDP are skipped, valid ones returned', async () => { + const apiCall = jest.fn().mockResolvedValue({ + entitlements: [ + { + actionsPerAttributeValueFqn: { + 'https://example.com/attr/clearance/value/secret': {}, + 'not-a-fqn': {}, + }, + }, + ], + }); + const store = new VirtruAttributeStore(mkClient({ apiCall })); + const r = await store.list(actor); + expect(r.attributes).toEqual([{ _id: 'clearance', key: 'clearance', values: ['secret'] }]); + }); + + it('isAvailable() false => throws PdpUnavailable, no GetEntitlements', async () => { + const apiCall = jest.fn(); + const store = new VirtruAttributeStore(mkClient({ isAvailable: jest.fn().mockResolvedValue(false), apiCall })); + await expect(store.list(actor)).rejects.toMatchObject({ code: 'error-pdp-unavailable' }); + expect(apiCall).not.toHaveBeenCalled(); + }); + + it('N concurrent calls for same entity before resolve => exactly one round-trip', async () => { + let resolve!: (v: unknown) => void; + const apiCall = jest.fn().mockReturnValue( + new Promise((r) => { + resolve = r; + }), + ); + const store = new VirtruAttributeStore(mkClient({ apiCall })); + const p = Promise.all([store.entitlementsOf(actor), store.entitlementsOf(actor), store.entitlementsOf(actor)]); + resolve({ entitlements: [{ actionsPerAttributeValueFqn: { 'https://example.com/attr/k/value/v': {} } }] }); + await p; + expect(apiCall).toHaveBeenCalledTimes(1); + }); + + it('cache keyed by resolved entity id (different entity => separate fetch)', async () => { + const apiCall = jest.fn().mockResolvedValue({ entitlements: [{ actionsPerAttributeValueFqn: {} }] }); + const store = new VirtruAttributeStore(mkClient({ apiCall })); + await store.entitlementsOf(actor); + usersFindOneById.mockResolvedValue({ _id: 'u2', emails: [{ address: 'alice@x.com' }], username: 'alice' }); + await store.entitlementsOf({ _id: 'u2', username: 'alice', name: 'A' }); + expect(apiCall).toHaveBeenCalledTimes(2); + }); + + it('validateAssignable rejects an unpossessed value', async () => { + const apiCall = jest + .fn() + .mockResolvedValue({ entitlements: [{ actionsPerAttributeValueFqn: { 'https://example.com/attr/clearance/value/secret': {} } }] }); + const store = new VirtruAttributeStore(mkClient({ apiCall })); + await expect(store.validateAssignable([{ key: 'clearance', values: ['secret'] }], actor)).resolves.toBeUndefined(); + await expect(store.validateAssignable([{ key: 'clearance', values: ['topsecret'] }], actor)).rejects.toMatchObject({ + code: 'error-invalid-attribute-values', + }); + expect(apiCall).toHaveBeenCalledTimes(1); + }); + + it('failed fetch is NOT cached: second call after PDP recovery succeeds', async () => { + const isAvailable = jest.fn().mockResolvedValue(false); + const apiCall = jest.fn().mockResolvedValue({ + entitlements: [{ actionsPerAttributeValueFqn: { 'https://example.com/attr/clearance/value/secret': {} } }], + }); + const store = new VirtruAttributeStore(mkClient({ isAvailable, apiCall })); + + await expect(store.entitlementsOf(actor)).rejects.toMatchObject({ code: 'error-pdp-unavailable' }); + + isAvailable.mockResolvedValue(true); + + const result = await store.entitlementsOf(actor); + expect(result.get('clearance')).toEqual(new Set(['secret'])); + expect(apiCall).toHaveBeenCalledTimes(1); + }); + + it('cached entry is auto-evicted after TTL elapses', async () => { + jest.useFakeTimers(); + try { + const apiCall = jest.fn().mockResolvedValue({ + entitlements: [{ actionsPerAttributeValueFqn: { 'https://example.com/attr/clearance/value/secret': {} } }], + }); + const store = new VirtruAttributeStore(mkClient({ apiCall })); + + await store.entitlementsOf(actor); + expect(apiCall).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(15_001); + + await store.entitlementsOf(actor); + expect(apiCall).toHaveBeenCalledTimes(2); + } finally { + jest.useRealTimers(); + } + }); + + it("failing fetch for one entity does NOT evict another entity's cached entitlements", async () => { + const apiCall = jest.fn(); + apiCall.mockResolvedValueOnce({ + entitlements: [{ actionsPerAttributeValueFqn: { 'https://example.com/attr/clearance/value/secret': {} } }], + }); + const store = new VirtruAttributeStore(mkClient({ apiCall })); + + await store.entitlementsOf(actor); + expect(apiCall).toHaveBeenCalledTimes(1); + + apiCall.mockRejectedValueOnce(new Error('pdp blip')); + usersFindOneById.mockResolvedValue({ _id: 'u2', emails: [{ address: 'alice@x.com' }], username: 'alice' }); + await expect(store.entitlementsOf({ _id: 'u2', username: 'alice', name: 'A' })).rejects.toThrow('pdp blip'); + expect(apiCall).toHaveBeenCalledTimes(2); + + usersFindOneById.mockResolvedValue({ _id: 'u1', emails: [{ address: 'bob@x.com' }], username: 'bob' }); + const result = await store.entitlementsOf(actor); + expect(result.get('clearance')).toEqual(new Set(['secret'])); + expect(apiCall).toHaveBeenCalledTimes(2); + }); + + it('onStoreSelected() forces the next entitlementsOf to re-hit the PDP', async () => { + const apiCall = jest.fn().mockResolvedValue({ + entitlements: [{ actionsPerAttributeValueFqn: { 'https://example.com/attr/clearance/value/secret': {} } }], + }); + const store = new VirtruAttributeStore(mkClient({ apiCall })); + + await store.entitlementsOf(actor); + await store.entitlementsOf(actor); + expect(apiCall).toHaveBeenCalledTimes(1); + + store.onStoreSelected(); + + await store.entitlementsOf(actor); + expect(apiCall).toHaveBeenCalledTimes(2); + }); + + it('unresolvable entity => entity-resolution error', async () => { + usersFindOneById.mockResolvedValue({ _id: 'u1', emails: [], username: 'bob' }); + const store = new VirtruAttributeStore(mkClient()); + await expect(store.list(actor)).rejects.toMatchObject({ code: 'error-virtru-entity-resolution-failed' }); + }); +}); + +describe('VirtruAttributeStore.scopeRoomsPage', () => { + const rooms = [ + { _id: 'rPermit', abacAttributes: [{ key: 'clearance', values: ['secret'] }] }, + { _id: 'rDeny', abacAttributes: [{ key: 'clearance', values: ['topsecret'] }] }, + ]; + it('PERMIT unchanged, DENY redacted+flagged, ONE round-trip, order kept', async () => { + const apiCall = jest.fn().mockResolvedValue({ + decisionResponses: [ + { + resourceDecisions: [ + { ephemeralResourceId: 'rPermit', decision: 'DECISION_PERMIT' }, + { ephemeralResourceId: 'rDeny', decision: 'DECISION_DENY' }, + ], + }, + ], + }); + const store = new VirtruAttributeStore(mkClient({ apiCall })); + const out = await store.scopeRoomsPage(rooms, actor); + expect(out.map((r) => r._id)).toEqual(['rPermit', 'rDeny']); + expect(out[0].abacAttributes).toEqual([{ key: 'clearance', values: ['secret'] }]); + expect(out[0]).not.toHaveProperty('abacAttributesRedacted'); + expect(out[1].abacAttributes).toEqual([]); + expect(out[1].abacAttributesRedacted).toBe(true); + expect(apiCall).toHaveBeenCalledTimes(1); + }); + it('isAvailable false => every room redacted, no throw, no apiCall', async () => { + const apiCall = jest.fn(); + const store = new VirtruAttributeStore(mkClient({ isAvailable: jest.fn().mockResolvedValue(false), apiCall })); + const out = await store.scopeRoomsPage(rooms, actor); + expect(out[0].abacAttributesRedacted).toBe(true); + expect(out[1].abacAttributesRedacted).toBe(true); + expect(apiCall).not.toHaveBeenCalled(); + }); + it('decision call throws => fail-closed redact all, no throw', async () => { + const apiCall = jest.fn().mockRejectedValue(new Error('boom')); + const store = new VirtruAttributeStore(mkClient({ apiCall })); + const out = await store.scopeRoomsPage(rooms, actor); + expect(out[0].abacAttributesRedacted).toBe(true); + expect(out[1].abacAttributesRedacted).toBe(true); + }); +}); + +describe('VirtruAttributeStore.assertCanModifyRoom', () => { + it("resolves when PDP PERMITs the room's current attributes", async () => { + const apiCall = jest + .fn() + .mockResolvedValue({ decisionResponses: [{ resourceDecisions: [{ ephemeralResourceId: 'r', decision: 'DECISION_PERMIT' }] }] }); + await expect( + new VirtruAttributeStore(mkClient({ apiCall })).assertCanModifyRoom( + { _id: 'r', abacAttributes: [{ key: 'k', values: ['v'] }] }, + actor, + ), + ).resolves.toBeUndefined(); + }); + + it('rejects with not-authorized when PDP DENYs', async () => { + const apiCall = jest + .fn() + .mockResolvedValue({ decisionResponses: [{ resourceDecisions: [{ ephemeralResourceId: 'r', decision: 'DECISION_DENY' }] }] }); + await expect( + new VirtruAttributeStore(mkClient({ apiCall })).assertCanModifyRoom( + { _id: 'r', abacAttributes: [{ key: 'k', values: ['v'] }] }, + actor, + ), + ).rejects.toMatchObject({ code: 'error-abac-not-authorized-to-modify-room' }); + }); + + it('rejects with PdpUnavailable when the decision call itself fails', async () => { + const apiCall = jest.fn().mockRejectedValue(new Error('network down')); + await expect( + new VirtruAttributeStore(mkClient({ apiCall })).assertCanModifyRoom( + { _id: 'r', abacAttributes: [{ key: 'k', values: ['v'] }] }, + actor, + ), + ).rejects.toMatchObject({ code: 'error-pdp-unavailable' }); + }); + + it('resolves when the room has no current attributes', async () => { + await expect( + new VirtruAttributeStore(mkClient()).assertCanModifyRoom({ _id: 'r', abacAttributes: [] }, actor), + ).resolves.toBeUndefined(); + }); + + it('rejects (fail-closed) when Virtru is unreachable', async () => { + await expect( + new VirtruAttributeStore(mkClient({ isAvailable: jest.fn().mockResolvedValue(false) })).assertCanModifyRoom( + { _id: 'r', abacAttributes: [{ key: 'k', values: ['v'] }] }, + actor, + ), + ).rejects.toMatchObject({ code: 'error-pdp-unavailable' }); + }); +}); diff --git a/ee/packages/abac/src/store/VirtruAttributeStore.ts b/ee/packages/abac/src/store/VirtruAttributeStore.ts new file mode 100644 index 0000000000000..6ff21cecf9053 --- /dev/null +++ b/ee/packages/abac/src/store/VirtruAttributeStore.ts @@ -0,0 +1,187 @@ +import type { AbacActor } from '@rocket.chat/core-services'; +import type { IAbacAttribute, IAbacAttributeDefinition, IRoom, IRoomAbacRedaction } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; +import mem from 'mem'; + +import { + AbacEntityResolutionFailedError, + AbacInvalidAttributeValuesError, + AbacNotAuthorizedToModifyRoomError, + PdpUnavailableError, +} from '../errors'; +import { logger } from '../logger'; +import type { AttributeEntitlements, IAttributeStore, ListAttributesOptions, ListAttributesResult } from './types'; +import type { VirtruClient } from '../clients/virtru/VirtruClient'; +import { buildAttributeFqns, buildEntityIdentifier, getUserEntityKey, parseAttributeFqns } from '../clients/virtru/identity'; +import type { IGetDecisionBulkRequest, IGetDecisionBulkResponse, IGetEntitlementsRequest, IGetEntitlementsResponse } from '../pdp/types'; + +const storeLogger = logger.section('VirtruAttributeStore'); + +const ENTITLEMENTS_CACHE_MS = 15_000; + +export class VirtruAttributeStore implements IAttributeStore { + private client: VirtruClient; + + private readonly entitlementsCache = new Map; maxAge: number }>(); + + private _entitlementsForEntity: (entityId: string) => Promise; + + constructor(client: VirtruClient) { + this.client = client; + this._entitlementsForEntity = mem( + (entityId: string) => { + const p = this.fetchEntitlements(entityId); + p.catch(() => { + this.entitlementsCache.delete(entityId); + }); + return p; + }, + { + maxAge: ENTITLEMENTS_CACHE_MS, + cacheKey: (args: [string]) => args[0], + cache: this.entitlementsCache, + }, + ); + } + + private async resolveEntityId(actor: AbacActor): Promise { + const { defaultEntityKey } = this.client.getConfig(); + const fullUser = await Users.findOneById(actor._id, { projection: { _id: 1, emails: 1, username: 1 } }); + const entityKey = fullUser && getUserEntityKey(defaultEntityKey, fullUser); + if (!entityKey) { + throw new AbacEntityResolutionFailedError(); + } + return entityKey; + } + + private async fetchEntitlements(entityId: string): Promise { + if (!(await this.client.isAvailable())) { + throw new PdpUnavailableError(); + } + const { defaultEntityKey } = this.client.getConfig(); + const request: IGetEntitlementsRequest = { + entityIdentifier: { + entityChain: { + entities: [buildEntityIdentifier(defaultEntityKey, entityId)], + }, + }, + withComprehensiveHierarchy: true, + }; + const res = await this.client.apiCall('/authorization.v2.AuthorizationService/GetEntitlements', request); + const { attributes, malformed } = parseAttributeFqns(Object.keys(res.entitlements?.[0]?.actionsPerAttributeValueFqn ?? {})); + if (malformed.length) { + storeLogger.warn({ msg: 'Virtru store: ignoring malformed attribute FQNs', malformed }); + } + return attributes; + } + + private async getEntitlements(actor: AbacActor): Promise { + const entityId = await this.resolveEntityId(actor); + return this._entitlementsForEntity(entityId); + } + + async list(actor: AbacActor | undefined, opts?: ListAttributesOptions): Promise { + if (!actor) { + throw new AbacEntityResolutionFailedError(); + } + let attributes = await this.getEntitlements(actor); + const filter = (opts?.key ?? opts?.values)?.trim().toLowerCase(); + if (filter) { + attributes = attributes + .map((a) => ({ key: a.key, values: a.values.filter((v) => v.toLowerCase().includes(filter)) })) + .filter((a) => a.key.toLowerCase().includes(filter) || a.values.length > 0); + } + const total = attributes.length; + const offset = opts?.offset ?? 0; + const count = opts?.count ?? total; + const slice = attributes.slice(offset, offset + count); + return { + attributes: slice.map((a) => ({ _id: a.key, ...a })) as IAbacAttribute[], + offset, + count: slice.length, + total, + }; + } + + async entitlementsOf(actor: AbacActor): Promise { + const defs = await this.getEntitlements(actor); + return new Map(defs.map((d) => [d.key, new Set(d.values)])); + } + + onStoreSelected(): void { + this.entitlementsCache.clear(); + } + + async validateAssignable(attrs: IAbacAttributeDefinition[], actor: AbacActor): Promise { + const owned = await this.entitlementsOf(actor); + for (const a of attrs) { + const allowed = owned.get(a.key); + if (!allowed || !a.values.every((v) => allowed.has(v))) { + throw new AbacInvalidAttributeValuesError(); + } + } + } + + async scopeRoomsPage>( + rooms: T[], + actor: AbacActor, + ): Promise> { + if (!rooms.length) { + return rooms; + } + const permitted = await this.decideRooms(rooms, actor).catch((err) => { + storeLogger.warn({ msg: 'Virtru store: redacting all rooms after decision failure', err }); + return new Set(); + }); + return rooms.map((r) => (permitted.has(r._id) ? r : { ...r, abacAttributes: [], abacAttributesRedacted: true })); + } + + async assertCanModifyRoom(room: Pick, actor: AbacActor): Promise { + if (!room.abacAttributes?.length) { + return; + } + const permitted = await this.decideRooms([room], actor); + if (!permitted.has(room._id)) { + throw new AbacNotAuthorizedToModifyRoomError(); + } + } + + private async decideRooms(rooms: Array>, actor: AbacActor): Promise> { + if (!(await this.client.isAvailable())) { + throw new PdpUnavailableError(); + } + const cfg = this.client.getConfig(); + const entityId = await this.resolveEntityId(actor); + const requests: IGetDecisionBulkRequest[] = rooms.map((room) => ({ + entityIdentifier: { + entityChain: { + entities: [buildEntityIdentifier(cfg.defaultEntityKey, entityId)], + }, + }, + action: { name: 'read' }, + resources: [ + { + ephemeralId: room._id, + attributeValues: { fqns: buildAttributeFqns(cfg.attributeNamespace, room.abacAttributes ?? []) }, + }, + ], + })); + let res: IGetDecisionBulkResponse; + try { + res = await this.client.apiCall('/authorization.v2.AuthorizationService/GetDecisionBulk', { + decisionRequests: requests, + }); + } catch (err) { + throw new PdpUnavailableError(); + } + const permitted = new Set(); + for (const dr of res.decisionResponses ?? []) { + for (const rd of dr.resourceDecisions ?? []) { + if (rd.decision === 'DECISION_PERMIT' && rd.ephemeralResourceId) { + permitted.add(rd.ephemeralResourceId); + } + } + } + return permitted; + } +} diff --git a/ee/packages/abac/src/store/index.ts b/ee/packages/abac/src/store/index.ts new file mode 100644 index 0000000000000..b80a5b36490e0 --- /dev/null +++ b/ee/packages/abac/src/store/index.ts @@ -0,0 +1,3 @@ +export { LocalAttributeStore } from './LocalAttributeStore'; +export { VirtruAttributeStore } from './VirtruAttributeStore'; +export type { IAttributeStore, AttributeEntitlements, AttributeStoreDescriptor, AttributeStoreSelectionContext } from './types'; diff --git a/ee/packages/abac/src/store/types.ts b/ee/packages/abac/src/store/types.ts new file mode 100644 index 0000000000000..b9703ae155e91 --- /dev/null +++ b/ee/packages/abac/src/store/types.ts @@ -0,0 +1,33 @@ +import type { AbacActor } from '@rocket.chat/core-services'; +import type { AbacPdpType, IAbacAttribute, IAbacAttributeDefinition, IRoom, IRoomAbacRedaction } from '@rocket.chat/core-typings'; + +export type AttributeEntitlements = Map>; + +export type ListAttributesOptions = { key?: string; values?: string; offset?: number; count?: number }; + +export type ListAttributesResult = { attributes: IAbacAttribute[]; offset: number; count: number; total: number }; + +export interface IAttributeStore { + list(actor: AbacActor | undefined, opts?: ListAttributesOptions): Promise; + + validateAssignable(attrs: IAbacAttributeDefinition[], actor: AbacActor): Promise; + + entitlementsOf(actor: AbacActor): Promise; + + scopeRoomsPage>(rooms: T[], actor: AbacActor): Promise>; + + assertCanModifyRoom(room: Pick, actor: AbacActor): Promise; + + onStoreSelected?(): void; +} + +export type AttributeStoreSelectionContext = { + abacEnabled: boolean; + pdpType?: AbacPdpType; + licensed: boolean; +}; + +export type AttributeStoreDescriptor = { + store: IAttributeStore; + isEligible: (ctx: AttributeStoreSelectionContext) => boolean; +}; diff --git a/ee/packages/abac/src/user-auto-removal.spec.ts b/ee/packages/abac/src/user-auto-removal.spec.ts index e401da82e5de6..accab85f43dbf 100644 --- a/ee/packages/abac/src/user-auto-removal.spec.ts +++ b/ee/packages/abac/src/user-auto-removal.spec.ts @@ -20,6 +20,12 @@ jest.mock('@rocket.chat/core-services', () => ({ api: { broadcast: jest.fn(), }, + License: { + hasModule: async () => true, + }, + Authorization: { + hasPermission: async () => false, + }, MeteorError: class extends Error {}, isMeteorError: () => false, })); diff --git a/packages/core-services/src/types/IAbacService.ts b/packages/core-services/src/types/IAbacService.ts index 17e9a19fe93bf..4d558d50d85d1 100644 --- a/packages/core-services/src/types/IAbacService.ts +++ b/packages/core-services/src/types/IAbacService.ts @@ -2,6 +2,7 @@ import type { IAbacAttributeDefinition, IAbacAttribute, IRoom, + IRoomAbacRedaction, IUser, AbacAccessOperation, AbacObjectType, @@ -29,7 +30,8 @@ export interface IAbacService { filterType?: 'all' | 'roomName' | 'attribute' | 'value'; }, actor?: AbacActor, - ): Promise<{ rooms: IRoom[]; offset: number; count: number; total: number }>; + ): Promise<{ rooms: Array; offset: number; count: number; total: number }>; + scopeRoomsForAdmin>(rooms: T[], actor: AbacActor): Promise>; updateAbacAttributeById(_id: string, update: { key?: string; values?: string[] }, actor: AbacActor | undefined): Promise; deleteAbacAttributeById(_id: string, actor: AbacActor | undefined): Promise; getAbacAttributeById(_id: string, actor: AbacActor | undefined): Promise<{ key: string; values: string[] }>; @@ -48,4 +50,5 @@ export interface IAbacService { addSubjectAttributes(user: IUser, ldapUser: ILDAPEntry, map: Record, actor: AbacActor | undefined): Promise; evaluateRoomMembership(): Promise; getPDPHealth(): Promise; + isExternalAttributeStore(): Promise; } diff --git a/packages/core-typings/src/Abac.ts b/packages/core-typings/src/Abac.ts index a4a9cbad6962e..80bff941e9adb 100644 --- a/packages/core-typings/src/Abac.ts +++ b/packages/core-typings/src/Abac.ts @@ -1,3 +1,5 @@ +import type { AbacAttributeStoreType, AbacPdpType } from './ServerAudit/IAuditServerAbacAction'; + export enum AbacAccessOperation { READ = 'read', WRITE = 'write', @@ -7,3 +9,7 @@ export enum AbacObjectType { ROOM = 'room', // Just room for now :) } + +export const isAbacPdpType = (value: unknown): value is AbacPdpType => value === 'local' || value === 'virtru'; + +export const isAbacAttributeStoreType = (value: unknown): value is AbacAttributeStoreType => value === 'local' || value === 'virtru'; diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts index f1fb4818b5f2f..14e5009dcfea0 100644 --- a/packages/core-typings/src/IRoom.ts +++ b/packages/core-typings/src/IRoom.ts @@ -388,6 +388,10 @@ export type RoomAdminFieldsType = export type IRoomAdmin = Pick; +export type IRoomAbacRedaction = { abacAttributesRedacted?: boolean }; + +export type IRoomAdminWithAbacRedaction = IRoomAdmin & IRoomAbacRedaction; + export interface IRoomWithRetentionPolicy extends IRoom { retention: { enabled?: boolean; diff --git a/packages/core-typings/src/IServerEvent.ts b/packages/core-typings/src/IServerEvent.ts index cde15a8e01ab7..75d97bc756f7f 100644 --- a/packages/core-typings/src/IServerEvent.ts +++ b/packages/core-typings/src/IServerEvent.ts @@ -3,6 +3,7 @@ import type { IUser } from './IUser'; import type { IServerEventAbacActionPerformed, IServerEventAbacAttributeChanged, + IServerEventAbacAttributeStoreSwitched, IServerEventAbacObjectAttributeChanged, IServerEventAbacObjectAttributesRemoved, IServerEventAbacSubjectAttributeChanged, @@ -72,6 +73,7 @@ export interface IServerEvents { 'abac.attribute.changed': IServerEventAbacAttributeChanged; 'abac.action.performed': IServerEventAbacActionPerformed; 'abac.object.attributes.removed': IServerEventAbacObjectAttributesRemoved; + 'abac.attribute.store.switched': IServerEventAbacAttributeStoreSwitched; 'settings.changed': IServerEventSettingsChanged; 'user.changed': IServerEventUserChanged; } diff --git a/packages/core-typings/src/ServerAudit/IAuditServerAbacAction.ts b/packages/core-typings/src/ServerAudit/IAuditServerAbacAction.ts index f044a1c375de6..5b077991d0b95 100644 --- a/packages/core-typings/src/ServerAudit/IAuditServerAbacAction.ts +++ b/packages/core-typings/src/ServerAudit/IAuditServerAbacAction.ts @@ -3,10 +3,19 @@ import type { IUser, IRoom, IAuditServerEventType, IAbacAttributeDefinition, ISe export type MinimalUser = Pick & Optional, '_id'>; export type MinimalRoom = Pick; -export type AbacAuditReason = 'ldap-sync' | 'room-attributes-change' | 'system' | 'api' | 'realtime-policy-eval' | 'virtru-pdp-sync'; +export type AbacAuditReason = + | 'ldap-sync' + | 'room-attributes-change' + | 'system' + | 'api' + | 'realtime-policy-eval' + | 'virtru-pdp-sync' + | 'attribute-store-switch'; export type AbacPdpType = 'local' | 'virtru'; +export type AbacAttributeStoreType = 'local' | 'virtru'; + export type AbacActionPerformed = 'revoked-object-access' | 'granted-object-access'; export type AbacAttributeDefinitionChangeType = @@ -72,6 +81,16 @@ export interface IServerEventAbacObjectAttributesRemoved t: 'abac.object.attributes.removed'; } +export interface IServerEventAbacAttributeStoreSwitched + extends IAuditServerEventType< + | { key: 'from'; value: AbacAttributeStoreType } + | { key: 'to'; value: AbacAttributeStoreType } + | { key: 'reason'; value: AbacAuditReason } + | { key: 'roomsAffected'; value: number } + > { + t: 'abac.attribute.store.switched'; +} + // Utility type to extract all ABAC-related server event names // (ensures that only events prefixed with "abac." are included) export type AbacAuditServerEventKey = Extract; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 3d361dc9ac675..921b3805a316b 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -26,6 +26,16 @@ "ABAC_PDP_Type_Description": "Select the Policy Decision Point engine to use for access control decisions.", "ABAC_PDP_Type_Local": "Local", "ABAC_PDP_Type_Virtru": "Virtru", + "ABAC_Attribute_Store": "Attribute store", + "ABAC_Attribute_Store_Description": "Where assignable room attributes come from. With Virtru, attributes are managed externally and limited to those the acting admin possesses; the Room Attributes tab is hidden.", + "ABAC_Attribute_Store_Local": "Local", + "ABAC_Attribute_Store_Virtru": "Virtru", + "ABAC_Attribute_Store_Switch_Alert": "Switching the attribute store permanently clears all existing room attribute assignments. This cannot be undone. No users are removed from any room, and rooms remain private.", + "ABAC_Attribute_Store_Switched": "Attribute store switched", + "ABAC_Rooms_Affected": { + "one": "{{count}} room affected", + "other": "{{count}} rooms affected" + }, "Abac_Cache_Decision_Time_Seconds": "ABAC Cache Decision Time (seconds)", "Abac_Cache_Decision_Time_Seconds_Description": "Time in seconds to cache access control decisions. Setting this value to 0 will disable caching.", "ABAC_Virtru_PDP_Configuration": "Virtru PDP Configuration", @@ -99,6 +109,8 @@ "manage-abac-admin-rooms_description": "Permission to view rooms and assign or remove ABAC attributes on them", "view-abac-admin-audit": "View ABAC audit log", "view-abac-admin-audit_description": "Permission to view the ABAC audit log", + "bypass-abac-store-validation": "Bypass ABAC store validation", + "bypass-abac-store-validation_description": "Permission to save or edit ABAC attributes without validating them against the attribute store", "abac_removed_user_from_the_room": "was removed by ABAC", "ABAC_No_attributes": "No Attributes", "ABAC_No_attributes_description": "Create custom characteristics to determine who can access a room.", @@ -118,6 +130,10 @@ "ABAC_Room_Attributes": "Room Attributes", "ABAC_Logs": "Logs", "ABAC_Invalid_attribute": "Invalid characters in attribute name or values", + "ABAC_Attributes_Redacted": "Room attributes hidden", + "ABAC_Attributes_Redacted_Description": "You don't have the required attributes.", + "ABAC_Redacted_Placeholder": "", + "ABAC_Picker_External_Store_Helper": "Available attributes are limited to those you possess.", "Alternative_text": "Alternative text", "Alt_text_description": "Describe the image to give context, including for blind and low-vision users and when it fails to load.", "Enable_ABAC_and_LDAP_to_sync": "Enable ABAC and LDAP to be able to sync", diff --git a/packages/rest-typings/src/v1/rooms.ts b/packages/rest-typings/src/v1/rooms.ts index eff60fe5c1ee5..d433992c15735 100644 --- a/packages/rest-typings/src/v1/rooms.ts +++ b/packages/rest-typings/src/v1/rooms.ts @@ -1,6 +1,7 @@ import type { IMessage, IRoom, + IRoomAbacRedaction, IUser, RoomAdminFieldsType, IUpload, @@ -901,11 +902,11 @@ export type RoomsEndpoints = { }; '/v1/rooms.adminRooms': { - GET: (params: RoomsAdminRoomsProps) => PaginatedResult<{ rooms: Pick[] }>; + GET: (params: RoomsAdminRoomsProps) => PaginatedResult<{ rooms: Array & IRoomAbacRedaction> }>; }; '/v1/rooms.adminRooms.getRoom': { - GET: (params: RoomsAdminRoomsGetRoomProps) => Pick; + GET: (params: RoomsAdminRoomsGetRoomProps) => Pick & IRoomAbacRedaction; }; '/v1/rooms.saveRoomSettings': { diff --git a/turbo.json b/turbo.json index 934b0e5327d07..9e1739c99ed3f 100644 --- a/turbo.json +++ b/turbo.json @@ -1,5 +1,6 @@ { "$schema": "https://turborepo.org/schema.json", + "globalPassThroughEnv": ["MONGOMS_DOWNLOAD_DIR", "MONGOMS_PREFER_GLOBAL_PATH"], "tasks": { "build-preview": { "dependsOn": ["^build"], From e02c01e65930b0a1843e378f74ca3e75c31d943b Mon Sep 17 00:00:00 2001 From: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:44:31 -0300 Subject: [PATCH 035/161] refactor: call history search queries (#40784) --- apps/meteor/app/api/server/v1/call-history.ts | 42 +++++------------ .../server/services/call-history/service.ts | 39 ++++++++++++++++ apps/meteor/server/services/startup.ts | 2 + packages/core-services/src/index.ts | 3 ++ .../src/types/ICallHistoryService.ts | 17 +++++++ .../src/models/ICallHistoryModel.ts | 17 +++++-- packages/models/src/models/CallHistory.ts | 46 +++++++++++++++++-- 7 files changed, 129 insertions(+), 37 deletions(-) create mode 100644 apps/meteor/server/services/call-history/service.ts create mode 100644 packages/core-services/src/types/ICallHistoryService.ts diff --git a/apps/meteor/app/api/server/v1/call-history.ts b/apps/meteor/app/api/server/v1/call-history.ts index b14c5621e9149..114cda89ed049 100644 --- a/apps/meteor/app/api/server/v1/call-history.ts +++ b/apps/meteor/app/api/server/v1/call-history.ts @@ -1,3 +1,4 @@ +import { CallHistory as CallHistoryService } from '@rocket.chat/core-services'; import type { CallHistoryItem, CallHistoryItemState, IMediaCall } from '@rocket.chat/core-typings'; import { CallHistory, MediaCalls } from '@rocket.chat/models'; import type { PaginatedRequest, PaginatedResult } from '@rocket.chat/rest-typings'; @@ -9,7 +10,6 @@ import { validateUnauthorizedErrorResponse, validateForbiddenErrorResponse, } from '@rocket.chat/rest-typings'; -import { escapeRegExp } from '@rocket.chat/string-helpers'; import { ensureArray } from '../../../../lib/utils/arrayUtils'; import type { ExtractRoutesFromAPI } from '../ApiClass'; @@ -104,37 +104,17 @@ const callHistoryListEndpoints = API.v1.get( const { direction, state, filter } = this.queryParams; - const filterText = typeof filter === 'string' && filter.trim(); - - const stateFilter = state && ensureArray(state); - const query = { - uid: this.userId, - ...(direction && { direction }), - ...(stateFilter?.length && { state: { $in: stateFilter } }), - ...(filterText && { - $or: [ - { - external: false, - contactName: { $regex: escapeRegExp(filterText), $options: 'i' }, - }, - { - external: false, - contactUsername: { $regex: escapeRegExp(filterText), $options: 'i' }, - }, - { - external: true, - contactExtension: { $regex: escapeRegExp(filterText), $options: 'i' }, - }, - ], - }), - }; + const searchTerm = typeof filter === 'string' && filter.trim(); - const { cursor, totalCount } = CallHistory.findPaginated(query, { - sort: sort || { ts: -1 }, - skip: offset, - limit: count, - }); - const [items, total] = await Promise.all([cursor.toArray(), totalCount]); + const { items, total } = await CallHistoryService.search( + this.userId, + { + ...(searchTerm && { searchTerm }), + ...(direction && { direction }), + ...(state && { inStates: ensureArray(state) }), + }, + { count, offset, sort }, + ); return API.v1.success({ items, diff --git a/apps/meteor/server/services/call-history/service.ts b/apps/meteor/server/services/call-history/service.ts new file mode 100644 index 0000000000000..cd3f2d0f966e6 --- /dev/null +++ b/apps/meteor/server/services/call-history/service.ts @@ -0,0 +1,39 @@ +import { ServiceClassInternal, type ICallHistoryService } from '@rocket.chat/core-services'; +import type { IUser, CallHistoryItem } from '@rocket.chat/core-typings'; +import { CallHistory } from '@rocket.chat/models'; + +export class CallHistoryService extends ServiceClassInternal implements ICallHistoryService { + protected name = 'call-history'; + + public async search( + uid: IUser['_id'], + filters: { + searchTerm?: string; + direction?: CallHistoryItem['direction']; + inStates?: CallHistoryItem['state'][]; + }, + pagination: { + count: number; + offset: number; + sort?: Record; + }, + ): Promise<{ items: CallHistoryItem[]; total: number }> { + const { offset, count, sort } = pagination || {}; + + const { cursor, totalCount } = CallHistory.findAllByUserIdAndSearchFilters( + uid, + { ...filters }, + { + sort: sort || { ts: -1 }, + skip: offset, + limit: count, + }, + ); + const [items, total] = await Promise.all([cursor.toArray(), totalCount]); + + return { + items, + total, + }; + } +} diff --git a/apps/meteor/server/services/startup.ts b/apps/meteor/server/services/startup.ts index 9892ce5ecf8cd..b0c14c2b49913 100644 --- a/apps/meteor/server/services/startup.ts +++ b/apps/meteor/server/services/startup.ts @@ -9,6 +9,7 @@ import { AnalyticsService } from './analytics/service'; import { AppsEngineService } from './apps-engine/service'; import { BannerService } from './banner/service'; import { CalendarService } from './calendar/service'; +import { CallHistoryService } from './call-history/service'; import { DeviceManagementService } from './device-management/service'; import { MediaService } from './image/service'; import { ImportService } from './import/service'; @@ -59,6 +60,7 @@ export const registerServices = async (): Promise => { api.registerService(new OmnichannelAnalyticsService()); api.registerService(new UserService()); api.registerService(new MediaCallService()); + api.registerService(new CallHistoryService()); // if the process is running in micro services mode we don't need to register services that will run separately if (!isRunningMs()) { diff --git a/packages/core-services/src/index.ts b/packages/core-services/src/index.ts index 540d20be1cc03..cb76365e2d18e 100644 --- a/packages/core-services/src/index.ts +++ b/packages/core-services/src/index.ts @@ -8,6 +8,7 @@ import type { IAuthorization, RoomAccessValidator } from './types/IAuthorization import type { IAuthorizationLivechat } from './types/IAuthorizationLivechat'; import type { IBannerService } from './types/IBannerService'; import type { ICalendarService } from './types/ICalendarService'; +import type { ICallHistoryService } from './types/ICallHistoryService'; import type { IDeviceManagementService } from './types/IDeviceManagementService'; import type { IEnterpriseSettings } from './types/IEnterpriseSettings'; import type { IFederationMatrixService } from './types/IFederationMatrixService'; @@ -143,6 +144,7 @@ export type { IUploadFileParams, IUploadService, ICalendarService, + ICallHistoryService, IOmnichannelTranscriptService, IQueueWorkerService, HealthAggResult, @@ -178,6 +180,7 @@ export const DeviceManagement = proxify('device-manage export const VideoConf = proxify('video-conference'); export const Upload = proxify('upload'); export const Calendar = proxify('calendar'); +export const CallHistory = proxify('call-history'); export const QueueWorker = proxify('queue-worker'); export const OmnichannelTranscript = proxify('omnichannel-transcript'); export const Message = proxify('message'); diff --git a/packages/core-services/src/types/ICallHistoryService.ts b/packages/core-services/src/types/ICallHistoryService.ts new file mode 100644 index 0000000000000..1fa1bcd5f38d4 --- /dev/null +++ b/packages/core-services/src/types/ICallHistoryService.ts @@ -0,0 +1,17 @@ +import type { CallHistoryItem, IUser } from '@rocket.chat/core-typings'; + +export interface ICallHistoryService { + search( + uid: IUser['_id'], + filters: { + searchTerm?: string; + direction?: CallHistoryItem['direction']; + inStates?: CallHistoryItem['state'][]; + }, + pagination: { + count: number; + offset: number; + sort?: Record; + }, + ): Promise<{ items: CallHistoryItem[]; total: number }>; +} diff --git a/packages/model-typings/src/models/ICallHistoryModel.ts b/packages/model-typings/src/models/ICallHistoryModel.ts index be2e5a51d85e3..750ee20a1a0e1 100644 --- a/packages/model-typings/src/models/ICallHistoryModel.ts +++ b/packages/model-typings/src/models/ICallHistoryModel.ts @@ -1,7 +1,7 @@ -import type { CallHistoryItem, IRegisterUser } from '@rocket.chat/core-typings'; -import type { FindOptions } from 'mongodb'; +import type { CallHistoryItem, IRegisterUser, IUser } from '@rocket.chat/core-typings'; +import type { FindCursor, FindOptions } from 'mongodb'; -import type { IBaseModel } from './IBaseModel'; +import type { FindPaginated, IBaseModel } from './IBaseModel'; export interface ICallHistoryModel extends IBaseModel { findOneByIdAndUid( @@ -16,5 +16,16 @@ export interface ICallHistoryModel extends IBaseModel { options?: FindOptions, ): Promise; + findAllByUserIdAndSearchFilters( + uid: IUser['_id'], + filters: { + type?: CallHistoryItem['type']; + searchTerm?: string; + direction?: CallHistoryItem['direction']; + inStates?: CallHistoryItem['state'][]; + }, + options: FindOptions, + ): FindPaginated>; + updateUserReferences(userId: IRegisterUser['_id'], username: IRegisterUser['username'], name?: IRegisterUser['name']): Promise; } diff --git a/packages/models/src/models/CallHistory.ts b/packages/models/src/models/CallHistory.ts index 59a014d9767a6..7160d91bcb905 100644 --- a/packages/models/src/models/CallHistory.ts +++ b/packages/models/src/models/CallHistory.ts @@ -1,6 +1,7 @@ -import type { CallHistoryItem, IRegisterUser } from '@rocket.chat/core-typings'; -import type { ICallHistoryModel } from '@rocket.chat/model-typings'; -import type { Db, FindOptions, IndexDescription } from 'mongodb'; +import type { CallHistoryItem, IRegisterUser, IUser } from '@rocket.chat/core-typings'; +import type { FindPaginated, ICallHistoryModel } from '@rocket.chat/model-typings'; +import { escapeRegExp } from '@rocket.chat/string-helpers'; +import type { Db, Filter, FindCursor, FindOptions, IndexDescription } from 'mongodb'; import { BaseRaw } from './BaseRaw'; @@ -46,4 +47,43 @@ export class CallHistoryRaw extends BaseRaw implements ICallHis }, ); } + + public findAllByUserIdAndSearchFilters( + uid: IUser['_id'], + filters: { + type?: CallHistoryItem['type']; + searchTerm?: string; + direction?: CallHistoryItem['direction']; + inStates?: CallHistoryItem['state'][]; + }, + options: FindOptions, + ): FindPaginated> { + const { type, direction, inStates, searchTerm } = filters; + + const textSearch = searchTerm ? { $regex: escapeRegExp(searchTerm), $options: 'i' } : null; + + const query: Filter = { + uid, + ...(type && { type }), + ...(direction && { direction }), + ...(inStates?.length && { state: { $in: inStates } }), + ...(textSearch && { + $or: [ + { + contactName: textSearch, + }, + { + external: false, + contactUsername: textSearch, + }, + { + external: true, + contactExtension: textSearch, + }, + ], + }), + }; + + return this.findPaginated(query, options); + } } From 94bf85db0859e9610d0d9c8b719acc1ae1bc5887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9ssica=20Souza?= Date: Wed, 3 Jun 2026 16:19:46 -0300 Subject: [PATCH 036/161] test: stabilize flaky omnichannel and livechat specs (#40775) --- .../tests/e2e/omnichannel/omnichannel-chat-transfers.spec.ts | 1 + .../omnichannel-livechat-agent-idle-setting.spec.ts | 5 ++++- .../omnichannel/omnichannel-livechat-read-receipts.spec.ts | 1 + .../meteor/tests/e2e/omnichannel/omnichannel-manager.spec.ts | 3 +-- .../tests/e2e/omnichannel/omnichannel-triggers.spec.ts | 1 + apps/meteor/tests/e2e/omnichannel/omnichannel-units.spec.ts | 5 +++-- .../e2e/page-objects/fragments/home-omnichannel-content.ts | 5 +++-- .../e2e/page-objects/omnichannel/omnichannel-manager.ts | 5 +++++ apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts | 3 +++ 9 files changed, 22 insertions(+), 7 deletions(-) diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-chat-transfers.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-chat-transfers.spec.ts index 3ffad2c3f31d7..71d98b4677e2e 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-chat-transfers.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-chat-transfers.spec.ts @@ -180,6 +180,7 @@ test.describe('OC - Chat transfers [Monitor role]', () => { await poOmnichannel.content.forwardChatModal.inputComment.type('any_comment'); await expect(poOmnichannel.content.forwardChatModal.btnForward).toBeEnabled(); await poOmnichannel.content.forwardChatModal.btnForward.click(); + await poOmnichannel.content.forwardChatModal.waitForDismissal(); // await expect(agentA.poHomeOmnichannel.toastSuccess).toBeVisible(); }); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts index a1b75af65e42f..570b8b18546f5 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-agent-idle-setting.spec.ts @@ -7,7 +7,7 @@ import { Users } from '../fixtures/userStates'; import { HomeOmnichannel } from '../page-objects'; import { OmnichannelLiveChat } from '../page-objects/omnichannel'; import { setSettingValueById } from '../utils'; -import { createAgent } from '../utils/omnichannel/agents'; +import { createAgent, makeAgentAvailable } from '../utils/omnichannel/agents'; import { test, expect } from '../utils/test'; test.use({ storageState: Users.user1.state }); @@ -41,9 +41,12 @@ test.describe('OC - Routing to Idle Agents', () => { test.beforeEach(async ({ page, browser, api }) => { visitor = createFakeVisitor(); + await expect(await makeAgentAvailable(api, agent.data._id)).toBeOK(); + poHomeOmnichannel = new HomeOmnichannel(page); await poHomeOmnichannel.page.goto('/'); await poHomeOmnichannel.waitForHome(); + await expect(poHomeOmnichannel.navbar.getUserStatusBadge('online')).toBeVisible(); ({ page: livechatPage } = await createAuxContext(browser, Users.user1, '/livechat', false)); poLivechat = new OmnichannelLiveChat(livechatPage, api); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-read-receipts.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-read-receipts.spec.ts index f8d8730597020..3cd8956d3e619 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-read-receipts.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-livechat-read-receipts.spec.ts @@ -70,6 +70,7 @@ test.describe('OC - Livechat - Read Receipts', () => { await expect(poHomeOmnichannel.content.lastUserMessage).toBeVisible(); await expect(poHomeOmnichannel.content.lastUserMessage).toContainText(testMessage); await markedAsRead; + await expect(poHomeOmnichannel.content.lastUserMessage.getByRole('status', { name: 'Message viewed' })).toBeVisible(); }); await test.step('read receipts show both agent and visitor names', async () => { diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-manager.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-manager.spec.ts index 1574928db18ce..cefee728381ac 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-manager.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-manager.spec.ts @@ -10,8 +10,7 @@ test.describe.serial('omnichannel-manager', () => { test.beforeEach(async ({ page }) => { poOmnichannelManagers = new OmnichannelManager(page); - await page.goto('/omnichannel'); - await poOmnichannelManagers.sidebar.linkManagers.click(); + await poOmnichannelManagers.goto(); }); test('OC - Manage Managers - Add, Search and Remove', async ({ page }) => { diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-triggers.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-triggers.spec.ts index f5a5a3c921cda..553b50a2fd17b 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-triggers.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-triggers.spec.ts @@ -132,6 +132,7 @@ test.describe.serial('OC - Livechat Triggers', () => { }); await test.step('expect to register visitor', async () => { + await expect(poLiveChat.inputName).toBeVisible(); await poLiveChat.sendMessage(newVisitor, false); }); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-units.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-units.spec.ts index 5a22e504f6945..68136fe66acf7 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-units.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-units.spec.ts @@ -60,7 +60,7 @@ test.describe('OC - Manage Units', () => { await poOmnichannelUnits.sidebar.linkUnits.click(); }); - test('OC - Manage Units - Create Unit', async ({ page }) => { + test('OC - Manage Units - Create Unit', async () => { const unitName = faker.string.uuid(); await test.step('expect correct form default state', async () => { @@ -78,6 +78,7 @@ test.describe('OC - Manage Units', () => { await poOmnichannelUnits.manageUnit.selectVisibility('public'); await poOmnichannelUnits.manageUnit.selectDepartment(department.data.name); await poOmnichannelUnits.manageUnit.selectMonitor('user2'); + await expect(poOmnichannelUnits.manageUnit.findMonitorChipOption('user2')).toBeVisible(); await poOmnichannelUnits.manageUnit.btnSave.click(); await expect(poOmnichannelUnits.manageUnit.root).not.toBeVisible(); @@ -87,7 +88,7 @@ test.describe('OC - Manage Units', () => { await test.step('expect to delete unit', async () => { await poOmnichannelUnits.deleteUnit(unitName); - await expect(page.locator('h3 >> text="No units yet"')).toBeVisible(); + await expect(poOmnichannelUnits.table.findRowByName(unitName)).toHaveCount(0); }); }); diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-omnichannel-content.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-omnichannel-content.ts index a933aebf975a2..3b6c0dcdd6e6a 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-omnichannel-content.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-omnichannel-content.ts @@ -40,8 +40,9 @@ export class HomeOmnichannelContent extends HomeContent { async selectCannedResponse(cannedResponseName: string): Promise { await this.composer.inputMessage.pressSequentially('!'); - await this.page.locator('[role="menu"][name="ComposerBoxPopup"]').waitFor({ state: 'visible' }); + const popup = this.composer.boxPopup; + await popup.waitFor({ state: 'visible' }); await this.composer.inputMessage.pressSequentially(cannedResponseName); - await this.page.keyboard.press('Enter'); + await popup.getByText(cannedResponseName, { exact: true }).click(); } } diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-manager.ts b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-manager.ts index 7a68257bdb6bf..882a3c5b87853 100644 --- a/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-manager.ts +++ b/apps/meteor/tests/e2e/page-objects/omnichannel/omnichannel-manager.ts @@ -21,6 +21,11 @@ export class OmnichannelManager extends OmnichannelAdmin { this.listbox = new Listbox(page); } + async goto() { + await this.page.goto('/omnichannel/managers'); + await this.btnAddManager.waitFor({ state: 'visible' }); + } + get inputUsername(): Locator { return this.page.getByRole('main').getByLabel('Username'); } diff --git a/apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts b/apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts index bd6731bfceb46..2e19f48f16e5c 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts @@ -12,6 +12,7 @@ import { addOrRemoveAgentFromDepartment, createDepartmentWithAnOnlineAgent, dele import { closeOmnichannelRoom, deleteVisitor, + makeAgentAvailable, placeRoomOnHold, sendAgentMessage, sendMessage, @@ -566,6 +567,8 @@ describe('LIVECHAT - dashboards', function () { const start = moment().subtract(1, 'days').toISOString(); const end = moment().toISOString(); + await Promise.all(agents.map((agent) => makeAgentAvailable(agent.credentials))); + const result = await request .get(api('livechat/analytics/dashboards/charts/agents-status')) .query({ start, end, departmentId: department._id }) From a03f7d1937c48983b2ad9a57584a4ab051402573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9ssica=20Souza?= Date: Wed, 3 Jun 2026 18:17:55 -0300 Subject: [PATCH 037/161] test: add tests for OAuth login launch flows (#40708) --- apps/meteor/tests/e2e/oauth.spec.ts | 166 ++++++++++++++++++++++++---- 1 file changed, 143 insertions(+), 23 deletions(-) diff --git a/apps/meteor/tests/e2e/oauth.spec.ts b/apps/meteor/tests/e2e/oauth.spec.ts index c93dc5a4f7dbe..6257d9d1a2b41 100644 --- a/apps/meteor/tests/e2e/oauth.spec.ts +++ b/apps/meteor/tests/e2e/oauth.spec.ts @@ -1,7 +1,34 @@ +import type { LoginServiceConfiguration } from '@rocket.chat/core-typings'; + +import { BASE_URL } from './config/constants'; import { Registration } from './page-objects'; import { setSettingValueById } from './utils/setSettingValueById'; +import type { BaseTest } from './utils/test'; import { test, expect } from './utils/test'; +type ServiceConfigurations = { + configurations: Array; +}; + +const customOAuthAuthorizeUrlPattern = /https:\/\/(www\.)?rocket\.chat\/(oauth\/)?authorize/; +const googleOAuthService = 'google'; +const customOAuthService = 'test'; + +const getOAuthService = async ( + api: BaseTest['api'], + service: string, +): Promise => { + const response = await api.get('/service.configurations'); + await expect(response).toBeOK(); + const { configurations } = (await response.json()) as ServiceConfigurations; + + return configurations.find((configuration) => configuration.service === service); +}; + +const getOAuthServiceLoginStyle = async (api: BaseTest['api'], serviceName: string): Promise => { + return (await getOAuthService(api, serviceName))?.loginStyle; +}; + test.describe('OAuth', () => { let poRegistration: Registration; @@ -9,43 +36,136 @@ test.describe('OAuth', () => { poRegistration = new Registration(page); }); - test('Login Page', async ({ page, api }) => { - await test.step('expect OAuth button to be visible', async () => { - await expect((await setSettingValueById(api, 'Accounts_OAuth_Google', true)).status()).toBe(200); - await page.waitForTimeout(5000); + test.describe('Login Page', () => { + test.afterEach(async ({ api }) => { + await setSettingValueById(api, 'Accounts_OAuth_Google', false); + await setSettingValueById(api, 'Accounts_OAuth_Custom-Test', false); + await setSettingValueById(api, 'Accounts_OAuth_Custom-Test-login_style', 'redirect'); + }); + + test('Login Page', async ({ page, api }) => { + await test.step('expect OAuth button to be visible', async () => { + await expect((await setSettingValueById(api, 'Accounts_OAuth_Google', true)).status()).toBe(200); + await expect.poll(async () => (await getOAuthService(api, googleOAuthService))?.service).toBe(googleOAuthService); + + await page.goto('/home'); + + await expect(poRegistration.btnLoginWithGoogle).toBeVisible(); + }); + + await test.step('expect Custom OAuth button to be visible', async () => { + await expect((await setSettingValueById(api, 'Accounts_OAuth_Custom-Test', true)).status()).toBe(200); + await expect.poll(async () => (await getOAuthService(api, customOAuthService))?.service).toBe(customOAuthService); + await page.goto('/home'); + + await expect(poRegistration.btnLoginWithCustomOAuth).toBeVisible(); + }); + + await test.step('expect redirect to the configured URL.', async () => { + await poRegistration.btnLoginWithCustomOAuth.click(); + await expect(page).toHaveURL(/https:\/\/(www)?\.rocket\.chat/); + }); + + await test.step('expect OAuth button to not be visible', async () => { + await expect((await setSettingValueById(api, 'Accounts_OAuth_Google', false)).status()).toBe(200); + await expect.poll(() => getOAuthService(api, googleOAuthService)).toBeUndefined(); + + await page.goto('/home'); + await expect(poRegistration.btnLoginWithGoogle).not.toBeVisible(); + }); + + await test.step('expect Custom OAuth button to not be visible', async () => { + await expect((await setSettingValueById(api, 'Accounts_OAuth_Custom-Test', false)).status()).toBe(200); + await expect.poll(() => getOAuthService(api, customOAuthService)).toBeUndefined(); + + await page.goto('/home'); + await expect(poRegistration.btnLoginWithCustomOAuth).not.toBeVisible(); + }); + }); + }); + + test.describe('Pop-up login style', () => { + test.beforeAll(async ({ api }) => { + await expect((await setSettingValueById(api, 'Accounts_OAuth_Custom-Test-login_style', 'popup')).status()).toBe(200); + await expect((await setSettingValueById(api, 'Accounts_OAuth_Custom-Test', true)).status()).toBe(200); + await expect.poll(() => getOAuthServiceLoginStyle(api, customOAuthService)).toBe('popup'); + }); + + test.afterAll(async ({ api }) => { + await setSettingValueById(api, 'Accounts_OAuth_Custom-Test', false); + await setSettingValueById(api, 'Accounts_OAuth_Custom-Test-login_style', 'redirect'); + }); + + test('open authorize flow in a popup', async ({ page }) => { + await test.step('expect Custom OAuth button to be visible', async () => { + await page.goto('/home'); + await expect(poRegistration.btnLoginWithCustomOAuth).toBeVisible(); + }); - await page.goto('/home'); + await test.step('expect authorize flow to be opened in a popup', async () => { + const [popup] = await Promise.all([page.waitForEvent('popup'), poRegistration.btnLoginWithCustomOAuth.click()]); - await expect(poRegistration.btnLoginWithGoogle).toBeVisible(); + await expect(popup).toHaveURL(customOAuthAuthorizeUrlPattern); + await expect(page).toHaveURL(/\/home/); + await popup.close(); + }); }); + }); - await test.step('expect Custom OAuth button to be visible', async () => { + test.describe('Proxy Redirect', () => { + test.beforeAll(async ({ api }) => { + await expect((await setSettingValueById(api, 'Accounts_OAuth_Proxy_services', 'test')).status()).toBe(200); + await expect((await setSettingValueById(api, 'Accounts_OAuth_Custom-Test-login_style', 'redirect')).status()).toBe(200); await expect((await setSettingValueById(api, 'Accounts_OAuth_Custom-Test', true)).status()).toBe(200); - await page.waitForTimeout(5000); - await page.goto('/home'); + await expect.poll(() => getOAuthServiceLoginStyle(api, customOAuthService)).toBe('redirect'); + }); - await expect(poRegistration.btnLoginWithCustomOAuth).toBeVisible(); + test.afterAll(async ({ api }) => { + await setSettingValueById(api, 'Accounts_OAuth_Custom-Test', false); + await setSettingValueById(api, 'Accounts_OAuth_Custom-Test-login_style', 'redirect'); + await setSettingValueById(api, 'Accounts_OAuth_Proxy_services', ''); }); - await test.step('expect redirect to the configured URL.', async () => { - await poRegistration.btnLoginWithCustomOAuth.click(); - await expect(page).toHaveURL(/https\:\/\/(www)?\.rocket\.chat/); + test('redirect login through the proxy', async ({ page }) => { + await test.step('expect Custom OAuth button to be visible', async () => { + await page.goto('/home'); + await expect(poRegistration.btnLoginWithCustomOAuth).toBeVisible(); + }); + + await test.step('expect authorize flow to be redirected through the proxy', async () => { + await poRegistration.btnLoginWithCustomOAuth.click(); + await expect(page).toHaveURL(/^https:\/\/oauth-proxy\.rocket\.chat\/redirect\//); + }); }); + }); - await test.step('expect OAuth button to not be visible', async () => { - await expect((await setSettingValueById(api, 'Accounts_OAuth_Google', false)).status()).toBe(200); - await page.waitForTimeout(5000); + test.describe('Iframe login', () => { + test.beforeAll(async ({ api }) => { + await expect((await setSettingValueById(api, 'Accounts_OAuth_Custom-Test-login_style', 'redirect')).status()).toBe(200); + await expect((await setSettingValueById(api, 'Accounts_OAuth_Custom-Test', true)).status()).toBe(200); + await expect.poll(() => getOAuthServiceLoginStyle(api, customOAuthService)).toBe('redirect'); + }); - await page.goto('/home'); - await expect(poRegistration.btnLoginWithGoogle).not.toBeVisible(); + test.afterAll(async ({ api }) => { + await setSettingValueById(api, 'Accounts_OAuth_Custom-Test', false); + await setSettingValueById(api, 'Accounts_OAuth_Custom-Test-login_style', 'redirect'); }); - await test.step('expect Custom OAuth button to not be visible', async () => { - await expect((await setSettingValueById(api, 'Accounts_OAuth_Custom-Test', false)).status()).toBe(200); - await page.waitForTimeout(5000); + test('login flow in iframe', async ({ page }) => { + await test.step('expect Custom OAuth button to be visible in iframe', async () => { + await page.goto('/'); + await page.setContent(``); + await expect(page.frameLocator('iframe[title="login"]').getByRole('button', { name: 'Sign in with Test' })).toBeVisible(); + }); + + await test.step('expect authorize flow to be started from the iframe', async () => { + const [oauthRequest] = await Promise.all([ + page.waitForRequest(customOAuthAuthorizeUrlPattern), + page.frameLocator('iframe[title="login"]').getByRole('button', { name: 'Sign in with Test' }).click(), + ]); - await page.goto('/home'); - await expect(poRegistration.btnLoginWithCustomOAuth).not.toBeVisible(); + expect(oauthRequest.url()).toMatch(customOAuthAuthorizeUrlPattern); + }); }); }); }); From 85fc0ddca134a875b10133751182eb9c65fd4724 Mon Sep 17 00:00:00 2001 From: Tasso Evangelista Date: Wed, 3 Jun 2026 22:53:35 -0300 Subject: [PATCH 038/161] refactor(uikit-playground): remove react-beautiful-dnd dependencies and update imports (#40795) --- apps/uikit-playground/package.json | 2 - .../Preview/Display/Surface/Surface.tsx | 2 +- yarn.lock | 109 +----------------- 3 files changed, 7 insertions(+), 106 deletions(-) diff --git a/apps/uikit-playground/package.json b/apps/uikit-playground/package.json index 392e9a3d9e1be..2fd47ac6ab815 100644 --- a/apps/uikit-playground/package.json +++ b/apps/uikit-playground/package.json @@ -33,7 +33,6 @@ "prettier": "~3.3.3", "rc-scrollbars": "^1.1.6", "react": "~18.3.1", - "react-beautiful-dnd": "^13.1.1", "react-dom": "~18.3.1", "react-router-dom": "^6.30.3", "react-split-pane": "^0.1.92", @@ -45,7 +44,6 @@ "@rocket.chat/tsconfig": "workspace:*", "@types/lodash": "~4.17.24", "@types/react": "~18.3.28", - "@types/react-beautiful-dnd": "^13.1.8", "@types/react-dom": "~18.3.7", "@vitejs/plugin-react": "~6.0.1", "eslint": "~9.39.4", diff --git a/apps/uikit-playground/src/Components/Preview/Display/Surface/Surface.tsx b/apps/uikit-playground/src/Components/Preview/Display/Surface/Surface.tsx index e3596079f0a1a..0897cc0587210 100644 --- a/apps/uikit-playground/src/Components/Preview/Display/Surface/Surface.tsx +++ b/apps/uikit-playground/src/Components/Preview/Display/Surface/Surface.tsx @@ -1,7 +1,7 @@ +import type { DropResult } from '@hello-pangea/dnd'; import { Box } from '@rocket.chat/fuselage'; import { UiKitContext } from '@rocket.chat/fuselage-ui-kit'; import { useContext, useState, useEffect } from 'react'; -import type { DropResult } from 'react-beautiful-dnd'; import { reorder } from './Reorder'; import SurfaceRender from './SurfaceRender'; diff --git a/yarn.lock b/yarn.lock index 815f20097cb54..bf30cdfcb734c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2545,7 +2545,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.22.5, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.9.2": +"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.22.5, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.7.6": version: 7.26.10 resolution: "@babel/runtime@npm:7.26.10" dependencies: @@ -11266,7 +11266,6 @@ __metadata: "@rocket.chat/ui-contexts": "workspace:~" "@types/lodash": "npm:~4.17.24" "@types/react": "npm:~18.3.28" - "@types/react-beautiful-dnd": "npm:^13.1.8" "@types/react-dom": "npm:~18.3.7" "@vitejs/plugin-react": "npm:~6.0.1" codemirror: "npm:^6.0.2" @@ -11275,7 +11274,6 @@ __metadata: prettier: "npm:~3.3.3" rc-scrollbars: "npm:^1.1.6" react: "npm:~18.3.1" - react-beautiful-dnd: "npm:^13.1.1" react-dom: "npm:~18.3.1" react-router-dom: "npm:^6.30.3" react-split-pane: "npm:^0.1.92" @@ -14436,16 +14434,6 @@ __metadata: languageName: node linkType: hard -"@types/hoist-non-react-statics@npm:^3.3.0": - version: 3.3.1 - resolution: "@types/hoist-non-react-statics@npm:3.3.1" - dependencies: - "@types/react": "npm:*" - hoist-non-react-statics: "npm:^3.3.0" - checksum: 10/071e6d75a0ed9aa0e9ca2cc529a8c15bf7ac3e4a37aac279772ea6036fd0bf969b67fb627b65cfce65adeab31fec1e9e95b4dcdefeab075b580c0c7174206f63 - languageName: node - linkType: hard - "@types/html-minifier-terser@npm:^6.0.0": version: 6.1.0 resolution: "@types/html-minifier-terser@npm:6.1.0" @@ -15015,15 +15003,6 @@ __metadata: languageName: node linkType: hard -"@types/react-beautiful-dnd@npm:^13.1.8": - version: 13.1.8 - resolution: "@types/react-beautiful-dnd@npm:13.1.8" - dependencies: - "@types/react": "npm:*" - checksum: 10/cb8a182ed0ea6f680c7c343fcb4e54237437ca90dc88c2a70f4a203ec8ad0740e389e3ab7772c8cc1479f05e5a046bccbc33d54055b94e0fe5b22be030ab23d2 - languageName: node - linkType: hard - "@types/react-dom@npm:~18.3.7": version: 18.3.7 resolution: "@types/react-dom@npm:18.3.7" @@ -15033,18 +15012,6 @@ __metadata: languageName: node linkType: hard -"@types/react-redux@npm:^7.1.20": - version: 7.1.25 - resolution: "@types/react-redux@npm:7.1.25" - dependencies: - "@types/hoist-non-react-statics": "npm:^3.3.0" - "@types/react": "npm:*" - hoist-non-react-statics: "npm:^3.3.0" - redux: "npm:^4.0.0" - checksum: 10/1c5780ff46b9a2bba3b68b26645ce9704cd3ef387141240c1369fcbef51370a84b8a5fc6ca27966f96f6e5b41618c88f498fedc7056870b207cbafbb4da34e91 - languageName: node - linkType: hard - "@types/react@npm:*": version: 18.3.23 resolution: "@types/react@npm:18.3.23" @@ -19367,7 +19334,7 @@ __metadata: languageName: node linkType: hard -"css-box-model@npm:^1.2.0, css-box-model@npm:^1.2.1": +"css-box-model@npm:^1.2.1": version: 1.2.1 resolution: "css-box-model@npm:1.2.1" dependencies: @@ -23881,15 +23848,6 @@ __metadata: languageName: node linkType: hard -"hoist-non-react-statics@npm:^3.3.0, hoist-non-react-statics@npm:^3.3.2": - version: 3.3.2 - resolution: "hoist-non-react-statics@npm:3.3.2" - dependencies: - react-is: "npm:^16.7.0" - checksum: 10/1acbe85f33e5a39f90c822ad4d28b24daeb60f71c545279431dc98c312cd28a54f8d64788e477fe21dc502b0e3cf58589ebe5c1ad22af27245370391c2d24ea6 - languageName: node - linkType: hard - "homedir-polyfill@npm:^1.0.0, homedir-polyfill@npm:^1.0.1": version: 1.0.3 resolution: "homedir-polyfill@npm:1.0.3" @@ -28122,13 +28080,6 @@ __metadata: languageName: node linkType: hard -"memoize-one@npm:^5.1.1": - version: 5.2.1 - resolution: "memoize-one@npm:5.2.1" - checksum: 10/b7141dc148b5c6fdd51e77ecf0421fd2581681eb8756e0b3dfbd4fe765b5e2b5a6bc90214bb6f19a96b6aed44de17eda3407142a7be9e24ccd0774bbd9874d1b - languageName: node - linkType: hard - "memoize-one@npm:^6.0.0": version: 6.0.0 resolution: "memoize-one@npm:6.0.0" @@ -31965,7 +31916,7 @@ __metadata: languageName: node linkType: hard -"raf-schd@npm:^4.0.2, raf-schd@npm:^4.0.3": +"raf-schd@npm:^4.0.3": version: 4.0.3 resolution: "raf-schd@npm:4.0.3" checksum: 10/45514041c5ad31fa96aef3bb3c572a843b92da2f2cd1cb4a47c9ad58e48761d3a4126e18daa32b2bfa0bc2551a42d8f324a0e40e536cb656969929602b4e8b58 @@ -32210,24 +32161,6 @@ __metadata: languageName: node linkType: hard -"react-beautiful-dnd@npm:^13.1.1": - version: 13.1.1 - resolution: "react-beautiful-dnd@npm:13.1.1" - dependencies: - "@babel/runtime": "npm:^7.9.2" - css-box-model: "npm:^1.2.0" - memoize-one: "npm:^5.1.1" - raf-schd: "npm:^4.0.2" - react-redux: "npm:^7.2.0" - redux: "npm:^4.0.4" - use-memo-one: "npm:^1.1.1" - peerDependencies: - react: ^16.8.5 || ^17.0.0 || ^18.0.0 - react-dom: ^16.8.5 || ^17.0.0 || ^18.0.0 - checksum: 10/2de8162a74f7fc78294e5a928b92d3fff02c579d137a25d53b1ab4313abeb108709bb7281512f7f94d18257de3122b8c85cb5a8375113cb8657088b1a9bda65b - languageName: node - linkType: hard - "react-docgen-typescript-plugin@npm:^1.0.8": version: 1.0.8 resolution: "react-docgen-typescript-plugin@npm:1.0.8" @@ -32342,14 +32275,14 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^16.13.1, react-is@npm:^16.7.0, react-is@npm:^16.8.6": +"react-is@npm:^16.13.1, react-is@npm:^16.8.6": version: 16.13.1 resolution: "react-is@npm:16.13.1" checksum: 10/5aa564a1cde7d391ac980bedee21202fc90bdea3b399952117f54fb71a932af1e5902020144fb354b4690b2414a0c7aafe798eb617b76a3d441d956db7726fdf languageName: node linkType: hard -"react-is@npm:^17.0.1, react-is@npm:^17.0.2": +"react-is@npm:^17.0.1": version: 17.0.2 resolution: "react-is@npm:17.0.2" checksum: 10/73b36281e58eeb27c9cc6031301b6ae19ecdc9f18ae2d518bdb39b0ac564e65c5779405d623f1df9abf378a13858b79442480244bd579968afc1faf9a2ce5e05 @@ -32392,27 +32325,6 @@ __metadata: languageName: node linkType: hard -"react-redux@npm:^7.2.0": - version: 7.2.9 - resolution: "react-redux@npm:7.2.9" - dependencies: - "@babel/runtime": "npm:^7.15.4" - "@types/react-redux": "npm:^7.1.20" - hoist-non-react-statics: "npm:^3.3.2" - loose-envify: "npm:^1.4.0" - prop-types: "npm:^15.7.2" - react-is: "npm:^17.0.2" - peerDependencies: - react: ^16.8.3 || ^17 || ^18 - peerDependenciesMeta: - react-dom: - optional: true - react-native: - optional: true - checksum: 10/1c3018bd2601e6d18339281867910b583dcbb3d8856403086e08c00abf0dfe467a94c0d1356bafa8cdf107bf1e2c9899a28486e4778e85c8bc4dfed2076b116f - languageName: node - linkType: hard - "react-redux@npm:^9.1.2": version: 9.2.0 resolution: "react-redux@npm:9.2.0" @@ -32809,15 +32721,6 @@ __metadata: languageName: node linkType: hard -"redux@npm:^4.0.0, redux@npm:^4.0.4": - version: 4.2.1 - resolution: "redux@npm:4.2.1" - dependencies: - "@babel/runtime": "npm:^7.9.2" - checksum: 10/371e4833b671193303a7dea7803c8fdc8e0d566740c78f580e0a3b77b4161da25037626900a2205a5d616117fa6ad09a4232e5a110bd437186b5c6355a041750 - languageName: node - linkType: hard - "redux@npm:^5.0.1": version: 5.0.1 resolution: "redux@npm:5.0.1" @@ -36938,7 +36841,7 @@ __metadata: languageName: node linkType: hard -"use-memo-one@npm:^1.1.1, use-memo-one@npm:^1.1.3": +"use-memo-one@npm:^1.1.3": version: 1.1.3 resolution: "use-memo-one@npm:1.1.3" peerDependencies: From afcc72d8d2096159ada47ac853f2c7534b5afa3e Mon Sep 17 00:00:00 2001 From: Julio Araujo Date: Thu, 4 Jun 2026 18:41:29 +0200 Subject: [PATCH 039/161] fix: escape HTML in exported data (#40802) --- .changeset/fast-apes-know.md | 5 ++ .../dataExport/exportRoomMessagesToFile.ts | 17 +++-- .../lib/dataExport/processDataDownloads.ts | 8 ++- .../server/lib/dataExport/sendViaEmail.ts | 7 +- .../exportRoomMessagesToFile.spec.ts | 69 +++++++++++++++++++ 5 files changed, 96 insertions(+), 10 deletions(-) create mode 100644 .changeset/fast-apes-know.md diff --git a/.changeset/fast-apes-know.md b/.changeset/fast-apes-know.md new file mode 100644 index 0000000000000..47242a936a4c2 --- /dev/null +++ b/.changeset/fast-apes-know.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Escapes HTML tags in exported data diff --git a/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts b/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts index 37fbe4d710a06..5ae8f45411c26 100644 --- a/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts +++ b/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts @@ -2,6 +2,7 @@ import { mkdir, writeFile } from 'node:fs/promises'; import type { IMessage, IRoom, IUser, MessageAttachment, FileProp, RoomType, IExportOperation } from '@rocket.chat/core-typings'; import { Messages } from '@rocket.chat/models'; +import { escapeHTML } from '@rocket.chat/string-helpers'; import { settings } from '../../../app/settings/server'; import { readSecondaryPreferred } from '../../database/readSecondaryPreferred'; @@ -175,9 +176,10 @@ export const exportMessageObject = (type: 'json' | 'html', messageObject: Messag const italicTypes: IMessage['t'][] = ['uj', 'ul', 'au', 'r', 'ru', 'wm', 'livechat-close']; - const message = italicTypes.includes(messageType) ? `${messageObject.msg}` : messageObject.msg; + const safeMsg = escapeHTML(messageObject.msg ?? ''); + const message = italicTypes.includes(messageType) ? `${safeMsg}` : safeMsg; - file.push(`

${messageObject.username} (${timestamp}):
`); + file.push(`

${escapeHTML(messageObject.username ?? '')} (${timestamp}):
`); file.push(message); for (const messageFile of messageFiles) { @@ -187,7 +189,7 @@ export const exportMessageObject = (type: 'json' | 'html', messageObject: Messag const description = attachment?.title || i18n.t('Message_Attachments'); const assetUrl = `./assets/${messageFile._id}-${messageFile.name}`; - const link = `
${description}`; + const link = `
${escapeHTML(description)}`; file.push(link); } } @@ -281,7 +283,14 @@ export const exportRoomMessagesToFile = async function ( if (exportOpRoomData.status === 'pending') { exportOpRoomData.status = 'exporting'; if (exportType === 'html') { - await writeFile(filePath, '', { encoding: 'utf8' }); + await writeFile( + filePath, + [ + '', + ``, + ].join('\n'), + { encoding: 'utf8' }, + ); } } diff --git a/apps/meteor/server/lib/dataExport/processDataDownloads.ts b/apps/meteor/server/lib/dataExport/processDataDownloads.ts index 41f8c693459f3..5c31fcb09c8a1 100644 --- a/apps/meteor/server/lib/dataExport/processDataDownloads.ts +++ b/apps/meteor/server/lib/dataExport/processDataDownloads.ts @@ -4,6 +4,7 @@ import { access, mkdir, rm, writeFile } from 'node:fs/promises'; import type { IExportOperation, IUser, RoomType } from '@rocket.chat/core-typings'; import { Avatars, ExportOperations, UserDataFiles, Subscriptions } from '@rocket.chat/models'; +import { escapeHTML } from '@rocket.chat/string-helpers'; import moment from 'moment'; import { FileUpload } from '../../../app/file-upload/server'; @@ -79,15 +80,16 @@ const generateUserFile = async (exportOperation: IExportOperation, userData?: IU stream.write('\n'); stream.write('\n'); + stream.write(`\n`); for (const [key, value] of Object.entries(dataToSave)) { - stream.write(`

${key}:`); + stream.write(`

${escapeHTML(key)}:`); if (typeof value === 'string') { - stream.write(value); + stream.write(escapeHTML(value)); } else if (Array.isArray(value)) { stream.write('
'); for (const item of value) { - stream.write(`${item}
`); + stream.write(`${escapeHTML(String(item))}
`); } } diff --git a/apps/meteor/server/lib/dataExport/sendViaEmail.ts b/apps/meteor/server/lib/dataExport/sendViaEmail.ts index dda337c851e44..834128a6a7f2a 100644 --- a/apps/meteor/server/lib/dataExport/sendViaEmail.ts +++ b/apps/meteor/server/lib/dataExport/sendViaEmail.ts @@ -1,5 +1,6 @@ import type { IMessage, IUser } from '@rocket.chat/core-typings'; import { Messages, Users } from '@rocket.chat/models'; +import { escapeHTML } from '@rocket.chat/string-helpers'; import moment from 'moment'; import * as Mailer from '../../../app/mailer/server/api'; @@ -67,9 +68,9 @@ export async function sendViaEmail( ) .map((message: IMessage) => { const dateTime = moment(message.ts).locale(lang).format('L LT'); - return `

${ - message.u.username - } ${dateTime}
${Message.parse(message, data.language)}

`; + return `

${escapeHTML( + message.u.username ?? '', + )} ${dateTime}
${Message.parse(message, data.language)}

`; }) .join(''); diff --git a/apps/meteor/tests/unit/server/lib/dataExport/exportRoomMessagesToFile.spec.ts b/apps/meteor/tests/unit/server/lib/dataExport/exportRoomMessagesToFile.spec.ts index 5fb3696f34fc1..d9c346d45c92e 100644 --- a/apps/meteor/tests/unit/server/lib/dataExport/exportRoomMessagesToFile.spec.ts +++ b/apps/meteor/tests/unit/server/lib/dataExport/exportRoomMessagesToFile.spec.ts @@ -114,6 +114,75 @@ describe('Export - exportMessageObject', () => { }); }); +describe('Export - exportMessageObject HTML escaping (XSS prevention)', () => { + const ts = new Date('2020-01-01T00:00:00.000Z'); + + it('should escape HTML in the message body when exporting as html', async () => { + const result = await exportMessageObject('html', { + msg: '', + username: 'attacker', + ts, + }); + + expect(result).to.contain('<img src=x onerror="alert(1)">'); + expect(result).to.not.contain(' { + const result = await exportMessageObject('html', { + msg: 'hello', + username: '', + ts, + }); + + expect(result).to.contain('<script>alert(1)</script>'); + expect(result).to.not.contain('', + }, + ], + }); + const result = normalizeMessagePreview(message, mockT); + + expect(result).toBe('<script>alert("xss")</script>'); }); it('should escape HTML in attachment title', () => { diff --git a/apps/meteor/client/lib/utils/normalizeMessagePreview/normalizeMessagePreview.ts b/apps/meteor/client/lib/utils/normalizeMessagePreview/normalizeMessagePreview.ts index 0fbefea5fa48d..53bf5bf2d4056 100644 --- a/apps/meteor/client/lib/utils/normalizeMessagePreview/normalizeMessagePreview.ts +++ b/apps/meteor/client/lib/utils/normalizeMessagePreview/normalizeMessagePreview.ts @@ -11,7 +11,11 @@ export const normalizeMessagePreview = (message: IMessage, t: TFunction): string } if (message.attachments) { - const attachment = message.attachments.find((attachment) => attachment.title); + const attachment = message.attachments.find((attachment) => attachment.title || attachment.description); + + if (attachment?.description) { + return escapeHTML(attachment.description); + } if (attachment?.title) { return escapeHTML(attachment.title); diff --git a/apps/meteor/client/views/room/MessageList/hooks/useMessageBody.tsx b/apps/meteor/client/views/room/MessageList/hooks/useMessageBody.tsx index 94313e6925432..0bcbbccece740 100644 --- a/apps/meteor/client/views/room/MessageList/hooks/useMessageBody.tsx +++ b/apps/meteor/client/views/room/MessageList/hooks/useMessageBody.tsx @@ -31,7 +31,11 @@ export const useMessageBody = (message: IMessage | undefined): string | Root => } if (message.attachments) { - const attachment = message.attachments.find((attachment) => attachment.title); + const attachment = message.attachments.find((attachment) => attachment.title || attachment.description); + + if (attachment?.description) { + return attachment.description; + } if (attachment?.title) { return attachment.title; diff --git a/apps/meteor/client/views/room/contextualBar/ExportMessages/useDownloadExportMutation.ts b/apps/meteor/client/views/room/contextualBar/ExportMessages/useDownloadExportMutation.ts index 291475075f4e4..f7b53352df5a1 100644 --- a/apps/meteor/client/views/room/contextualBar/ExportMessages/useDownloadExportMutation.ts +++ b/apps/meteor/client/views/room/contextualBar/ExportMessages/useDownloadExportMutation.ts @@ -39,6 +39,7 @@ export const useDownloadExportMutation = () => { ...('image_type' in attachment && { image_type: attachment.image_type }), ...('image_size' in attachment && { image_size: attachment.image_size }), ...('type' in attachment && { type: attachment.type }), + description: attachment.description, })) ?? [], }), ); diff --git a/apps/meteor/client/views/room/contextualBar/ExportMessages/useExportMessagesAsPDFMutation.tsx b/apps/meteor/client/views/room/contextualBar/ExportMessages/useExportMessagesAsPDFMutation.tsx index 383388f0c316f..716f7f3c8bd19 100644 --- a/apps/meteor/client/views/room/contextualBar/ExportMessages/useExportMessagesAsPDFMutation.tsx +++ b/apps/meteor/client/views/room/contextualBar/ExportMessages/useExportMessagesAsPDFMutation.tsx @@ -116,6 +116,7 @@ export const useExportMessagesAsPDFMutation = () => { {parseMessage(message)} {message.attachments?.map((attachment: MessageAttachmentDefault, index) => ( + {attachment.description && {attachment.description}} {attachment.image_url && } {attachment.title} diff --git a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useNormalizedThreadTitleHtml.ts b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useNormalizedThreadTitleHtml.ts index c052942a9567d..4ac708211cf47 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useNormalizedThreadTitleHtml.ts +++ b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useNormalizedThreadTitleHtml.ts @@ -33,7 +33,11 @@ export const useNormalizedThreadTitleHtml = (mainMessage: IThreadMainMessage) => } if (message.attachments) { - const attachment = message.attachments.find((attachment) => attachment.title); + const attachment = message.attachments.find((attachment) => attachment.title || attachment.description); + + if (attachment?.description) { + return escapeHTML(attachment.description); + } if (attachment?.title) { return escapeHTML(attachment.title); diff --git a/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts b/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts index d15ac6dbbc909..b2480acff247d 100644 --- a/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts +++ b/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts @@ -142,7 +142,11 @@ slashCommands.add({ return; } - const emailText = message?.msg || ''; + const emailText = + message?.attachments + ?.map((a) => a.description) + .filter(Boolean) + .join('\n\n') || ''; void sendEmail( inbox, diff --git a/apps/meteor/server/services/messages/hooks/BeforeSaveMarkdownParser.ts b/apps/meteor/server/services/messages/hooks/BeforeSaveMarkdownParser.ts index b2b47ad0b9228..088aff0c8b052 100644 --- a/apps/meteor/server/services/messages/hooks/BeforeSaveMarkdownParser.ts +++ b/apps/meteor/server/services/messages/hooks/BeforeSaveMarkdownParser.ts @@ -38,6 +38,10 @@ export class BeforeSaveMarkdownParser { if (message.msg) { message.md = parse(message.msg, config); } + + if (message.attachments?.[0]?.description) { + message.attachments[0].descriptionMd = parse(message.attachments[0].description, config); + } } catch (e) { console.error(e); // errors logged while the parser is at experimental stage } diff --git a/apps/meteor/tests/unit/server/services/messages/hooks/BeforeSaveJumpToMessage.tests.ts b/apps/meteor/tests/unit/server/services/messages/hooks/BeforeSaveJumpToMessage.tests.ts index 791404e4f1d9f..0ce7279e89b39 100644 --- a/apps/meteor/tests/unit/server/services/messages/hooks/BeforeSaveJumpToMessage.tests.ts +++ b/apps/meteor/tests/unit/server/services/messages/hooks/BeforeSaveJumpToMessage.tests.ts @@ -500,6 +500,17 @@ describe('Create attachments for message URLs', () => { image_size: 68016, type: 'file', description: 'chained 3 - file', + descriptionMd: [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'PLAIN_TEXT', + value: 'chained 3 - file', + }, + ], + }, + ], }, ], }, diff --git a/apps/meteor/tests/unit/server/services/messages/hooks/BeforeSaveMarkdownParser.tests.ts b/apps/meteor/tests/unit/server/services/messages/hooks/BeforeSaveMarkdownParser.tests.ts index c744883ce9e3c..c511b071ac1a0 100644 --- a/apps/meteor/tests/unit/server/services/messages/hooks/BeforeSaveMarkdownParser.tests.ts +++ b/apps/meteor/tests/unit/server/services/messages/hooks/BeforeSaveMarkdownParser.tests.ts @@ -82,4 +82,29 @@ describe('Markdown parser', () => { expect(message).to.have.property('md'); }); + + it('should parse markdown on the first attachment only', async () => { + const markdownParser = new BeforeSaveMarkdownParser(true); + + const message = await markdownParser.parseMarkdown({ + message: createMessage('hey', { + attachments: [ + { + description: 'hey ho', + }, + { + description: 'lets go', + }, + ], + }), + config: {}, + }); + + expect(message).to.have.property('md'); + + const [attachment1, attachment2] = message.attachments || []; + + expect(attachment1).to.have.property('descriptionMd'); + expect(attachment2).to.not.have.property('descriptionMd'); + }); }); diff --git a/ee/packages/omnichannel-services/src/OmnichannelTranscript.ts b/ee/packages/omnichannel-services/src/OmnichannelTranscript.ts index 67b9aa5ab16d9..4755688c9f006 100644 --- a/ee/packages/omnichannel-services/src/OmnichannelTranscript.ts +++ b/ee/packages/omnichannel-services/src/OmnichannelTranscript.ts @@ -312,7 +312,9 @@ export class OmnichannelTranscript extends ServiceClass implements IOmnichannelT } } - const msg = message.msg || ''; + // When you send a file message, the things you type in the modal are not "msg", they're in "description" of the attachment + // So, we'll fetch the the msg, if empty, go for the first description on an attachment, if empty, empty string + const msg = message.msg || message.attachments.find((attachment) => attachment.description)?.description || ''; // Remove nulls from final array messagesData.push({ msg, diff --git a/packages/core-typings/src/IMessage/MessageAttachment/MessageAttachmentBase.ts b/packages/core-typings/src/IMessage/MessageAttachment/MessageAttachmentBase.ts index 7d236bcafa2be..82ce0db3325ef 100644 --- a/packages/core-typings/src/IMessage/MessageAttachment/MessageAttachmentBase.ts +++ b/packages/core-typings/src/IMessage/MessageAttachment/MessageAttachmentBase.ts @@ -7,6 +7,7 @@ export type MessageAttachmentBase = { collapsed?: boolean; /** description isn't being used on client for non-image attachments, we're keeping it for backward compatibility */ description?: string; + descriptionMd?: Root; text?: string; md?: Root; size?: number; From 50b7933ae63a1b1d5b6b832f9869a167e2b84220 Mon Sep 17 00:00:00 2001 From: Tasso Evangelista Date: Tue, 9 Jun 2026 19:51:27 -0300 Subject: [PATCH 074/161] refactor: update event types from FormEvent to ChangeEvent in various components (#40823) --- .../NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx | 4 ++-- .../MatrixFederationManageServerModal.tsx | 4 ++-- .../MatrixFederationSearchModalContent.tsx | 4 ++-- .../meteor/client/views/account/security/TwoFactorEmail.tsx | 4 ++-- apps/meteor/client/views/account/security/TwoFactorTOTP.tsx | 4 ++-- .../client/views/admin/customSounds/AddCustomSound.tsx | 4 ++-- apps/meteor/client/views/admin/customSounds/EditSound.tsx | 4 ++-- apps/meteor/client/views/admin/import/NewImportPage.tsx | 6 +++--- .../permissions/PermissionsTable/PermissionsTableFilter.tsx | 4 ++-- .../admin/settings/Setting/inputs/BooleanSettingInput.tsx | 4 ++-- .../admin/settings/Setting/inputs/FontSettingInput.tsx | 4 ++-- .../admin/settings/Setting/inputs/GenericSettingInput.tsx | 4 ++-- .../views/admin/settings/Setting/inputs/IntSettingInput.tsx | 4 ++-- .../admin/settings/Setting/inputs/PasswordSettingInput.tsx | 4 ++-- .../settings/Setting/inputs/RelativeUrlSettingInput.tsx | 4 ++-- .../admin/settings/Setting/inputs/StringSettingInput.tsx | 4 ++-- .../admin/settings/Setting/inputs/TimespanSettingInput.tsx | 4 ++-- .../admin/settings/SettingsGroupPage/SettingsGroupPage.tsx | 2 +- .../client/views/admin/settings/groups/LDAPGroupPage.tsx | 6 +++--- .../views/admin/users/UsersTable/UsersTableFilters.tsx | 6 +++--- .../client/views/audit/components/forms/DateRangePicker.tsx | 6 +++--- .../views/mediaCallHistory/CallHistoryPageFilters.tsx | 6 +++--- .../client/views/omnichannel/analytics/DateRangePicker.tsx | 6 +++--- .../contextualBar/CannedResponse/CannedResponseList.tsx | 4 ++-- .../client/views/omnichannel/departments/DepartmentTags.tsx | 4 ++-- .../omnichannel/directory/contacts/RemoveContactModal.tsx | 4 ++-- .../Header/Omnichannel/QuickActions/QuickActionOptions.tsx | 2 +- .../client/views/room/composer/messageBox/MessageBox.tsx | 2 +- .../views/room/contextualBar/RoomMembers/RoomMembers.tsx | 4 ++-- .../client/views/room/contextualBar/Threads/ThreadList.tsx | 4 ++-- .../components/MultiSelectCustom/MultiSelectCustomList.tsx | 6 +++--- packages/ui-voip/src/providers/useMediaSession.ts | 2 +- .../CallHistoryContextualbar/CallHistoryActions.stories.tsx | 6 +++--- 33 files changed, 70 insertions(+), 70 deletions(-) diff --git a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx index e5cc289312bda..bbe2df4d903be 100644 --- a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx +++ b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/EditStatusModal.tsx @@ -20,7 +20,7 @@ import { } from '@rocket.chat/fuselage'; import { useEffectEvent, useLocalStorage } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useSetting, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; -import type { ChangeEvent, ComponentProps, FormEvent } from 'react'; +import type { ChangeEvent, ComponentProps } from 'react'; import { useState, useCallback, useId } from 'react'; import UserStatusMenu from '../../../components/UserStatusMenu'; @@ -76,7 +76,7 @@ const EditStatusModal = ({ onClose, userStatus, userStatusText }: EditStatusModa wrapperFunction={(props: ComponentProps) => ( { + onSubmit={(e) => { e.preventDefault(); handleSaveStatus(); }} diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationManageServerModal.tsx b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationManageServerModal.tsx index 2baaeac8753bd..c96b5d966bb5f 100644 --- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationManageServerModal.tsx +++ b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationManageServerModal.tsx @@ -18,7 +18,7 @@ import { import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useSetModal, useTranslation, useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import type { FormEvent } from 'react'; +import type { ChangeEvent } from 'react'; import { useState } from 'react'; import MatrixFederationRemoveServerList from './MatrixFederationRemoveServerList'; @@ -90,7 +90,7 @@ const MatrixFederationAddServerModal = ({ onClickClose }: MatrixFederationAddSer ) => { + onChange={(e: ChangeEvent) => { setServerName(e.currentTarget.value); if (errorKey) { setErrorKey(undefined); diff --git a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearchModalContent.tsx b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearchModalContent.tsx index 853df819ddbb5..ff5f5ab94fc15 100644 --- a/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearchModalContent.tsx +++ b/apps/meteor/client/sidebar/header/MatrixFederationSearch/MatrixFederationSearchModalContent.tsx @@ -2,7 +2,7 @@ import type { SelectOption } from '@rocket.chat/fuselage'; import { Box, Select, TextInput } from '@rocket.chat/fuselage'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; import { useSetModal } from '@rocket.chat/ui-contexts'; -import type { FormEvent } from 'react'; +import type { ChangeEvent } from 'react'; import { useCallback, useState, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -53,7 +53,7 @@ const MatrixFederationSearchModalContent = ({ defaultSelectedServer, servers }: flexGrow={4} flexShrink={0} value={roomName} - onChange={(e: FormEvent) => setRoomName(e.currentTarget.value)} + onChange={(e: ChangeEvent) => setRoomName(e.currentTarget.value)} /> diff --git a/apps/meteor/client/views/account/security/TwoFactorEmail.tsx b/apps/meteor/client/views/account/security/TwoFactorEmail.tsx index 17c261e8df5a0..5929662999cb2 100644 --- a/apps/meteor/client/views/account/security/TwoFactorEmail.tsx +++ b/apps/meteor/client/views/account/security/TwoFactorEmail.tsx @@ -1,6 +1,6 @@ import { Box, Field, FieldLabel, FieldRow, Margins, ToggleSwitch } from '@rocket.chat/fuselage'; import { useToastMessageDispatch, useUser } from '@rocket.chat/ui-contexts'; -import type { ComponentProps, FormEvent } from 'react'; +import type { ComponentProps, ChangeEvent } from 'react'; import { useCallback, useId } from 'react'; import { useTranslation } from 'react-i18next'; @@ -27,7 +27,7 @@ const TwoFactorEmail = (props: ComponentProps) => { }); const handleEnable = useCallback( - async (e: FormEvent) => { + async (e: ChangeEvent) => { if (e.currentTarget.checked) { await enable2faAction(); } else { diff --git a/apps/meteor/client/views/account/security/TwoFactorTOTP.tsx b/apps/meteor/client/views/account/security/TwoFactorTOTP.tsx index 26c51e32702b5..7bfb8cc3876d2 100644 --- a/apps/meteor/client/views/account/security/TwoFactorTOTP.tsx +++ b/apps/meteor/client/views/account/security/TwoFactorTOTP.tsx @@ -1,7 +1,7 @@ import { Box, Button, TextInput, Margins, Field, FieldRow, FieldLabel, ToggleSwitch } from '@rocket.chat/fuselage'; import { useEffectEvent, useSafely } from '@rocket.chat/fuselage-hooks'; import { useSetModal, useToastMessageDispatch, useUser, useMethod } from '@rocket.chat/ui-contexts'; -import type { ComponentPropsWithoutRef, FormEvent } from 'react'; +import type { ComponentPropsWithoutRef, ChangeEvent } from 'react'; import { useState, useCallback, useEffect, useId } from 'react'; import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -92,7 +92,7 @@ const TwoFactorTOTP = (props: TwoFactorTOTPProps) => { setModal(); }); - const handleToggleTotp = useEffectEvent(async (e: FormEvent) => { + const handleToggleTotp = useEffectEvent(async (e: ChangeEvent) => { if (e.currentTarget?.checked) { void enableTotp(); } else { diff --git a/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx b/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx index 187b4fb317bd5..e35ac9df9db23 100644 --- a/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx @@ -2,7 +2,7 @@ import { Field, FieldLabel, FieldRow, TextInput, Box, Margins, Button, ButtonGro import { ContextualbarScrollableContent, ContextualbarFooter } from '@rocket.chat/ui-client'; import { useToastMessageDispatch, type UploadResult } from '@rocket.chat/ui-contexts'; import fileSize from 'filesize'; -import type { FormEvent } from 'react'; +import type { ChangeEvent } from 'react'; import { useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -86,7 +86,7 @@ const AddCustomSound = ({ goToNew, close, onChange, ...props }: AddCustomSoundPr ): void => setName(e.currentTarget.value)} + onChange={(e: ChangeEvent): void => setName(e.currentTarget.value)} placeholder={t('Name')} /> diff --git a/apps/meteor/client/views/admin/customSounds/EditSound.tsx b/apps/meteor/client/views/admin/customSounds/EditSound.tsx index 54c338e444362..9fa3043b80ee8 100644 --- a/apps/meteor/client/views/admin/customSounds/EditSound.tsx +++ b/apps/meteor/client/views/admin/customSounds/EditSound.tsx @@ -2,7 +2,7 @@ import { Box, Button, ButtonGroup, Margins, TextInput, Field, FieldLabel, FieldR import { GenericModal, ContextualbarScrollableContent, ContextualbarFooter } from '@rocket.chat/ui-client'; import { useSetModal, useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts'; import fileSize from 'filesize'; -import type { SyntheticEvent } from 'react'; +import type { ChangeEvent } from 'react'; import { useCallback, useState, useMemo, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; @@ -117,7 +117,7 @@ function EditSound({ close, onChange, data, ...props }: EditSoundProps) { ): void => setName(e.currentTarget.value)} + onChange={(e: ChangeEvent): void => setName(e.currentTarget.value)} placeholder={t('Name')} /> diff --git a/apps/meteor/client/views/admin/import/NewImportPage.tsx b/apps/meteor/client/views/admin/import/NewImportPage.tsx index 8cd03322ed509..7fcc67e42cc33 100644 --- a/apps/meteor/client/views/admin/import/NewImportPage.tsx +++ b/apps/meteor/client/views/admin/import/NewImportPage.tsx @@ -19,7 +19,7 @@ import { Page, PageHeader, PageScrollableContentWithShadow } from '@rocket.chat/ import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useToastMessageDispatch, useRouter, useRouteParameter, useSetting, useEndpoint } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; -import type { ChangeEvent, DragEvent, FormEvent, Key, SyntheticEvent } from 'react'; +import type { ChangeEvent, DragEvent, Key, SyntheticEvent } from 'react'; import { useState, useMemo, useEffect, useId } from 'react'; import { useTranslation } from 'react-i18next'; @@ -146,7 +146,7 @@ function NewImportPage() { const [fileUrl, setFileUrl] = useSafely(useState('')); - const handleFileUrlChange = (event: FormEvent) => { + const handleFileUrlChange = (event: ChangeEvent) => { setFileUrl(event.currentTarget.value); }; @@ -170,7 +170,7 @@ function NewImportPage() { const [filePath, setFilePath] = useSafely(useState('')); - const handleFilePathChange = (event: FormEvent) => { + const handleFilePathChange = (event: ChangeEvent) => { setFilePath(event.currentTarget.value); }; diff --git a/apps/meteor/client/views/admin/permissions/PermissionsTable/PermissionsTableFilter.tsx b/apps/meteor/client/views/admin/permissions/PermissionsTable/PermissionsTableFilter.tsx index 0137b18a47c95..b70ed82b6d7f7 100644 --- a/apps/meteor/client/views/admin/permissions/PermissionsTable/PermissionsTableFilter.tsx +++ b/apps/meteor/client/views/admin/permissions/PermissionsTable/PermissionsTableFilter.tsx @@ -1,6 +1,6 @@ import { TextInput } from '@rocket.chat/fuselage'; import { useEffectEvent, useDebouncedValue } from '@rocket.chat/fuselage-hooks'; -import type { FormEvent } from 'react'; +import type { ChangeEvent } from 'react'; import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; @@ -13,7 +13,7 @@ const PermissionsTableFilter = ({ onChange }: { onChange: (debouncedFilter: stri onChange(debouncedFilter); }, [debouncedFilter, onChange]); - const handleFilter = useEffectEvent(({ currentTarget: { value } }: FormEvent) => { + const handleFilter = useEffectEvent(({ currentTarget: { value } }: ChangeEvent) => { setFilter(value); }); diff --git a/apps/meteor/client/views/admin/settings/Setting/inputs/BooleanSettingInput.tsx b/apps/meteor/client/views/admin/settings/Setting/inputs/BooleanSettingInput.tsx index acb4abb5c0db3..71c4207367d83 100644 --- a/apps/meteor/client/views/admin/settings/Setting/inputs/BooleanSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/Setting/inputs/BooleanSettingInput.tsx @@ -1,5 +1,5 @@ import { Box, Field, FieldHint, FieldLabel, FieldRow, ToggleSwitch } from '@rocket.chat/fuselage'; -import type { SyntheticEvent } from 'react'; +import type { ChangeEvent } from 'react'; import ResetSettingButton from '../ResetSettingButton'; import type { SettingInputProps } from './types'; @@ -18,7 +18,7 @@ function BooleanSettingInput({ onChangeValue, onResetButtonClick, }: BooleanSettingInputProps) { - const handleChange = (event: SyntheticEvent): void => { + const handleChange = (event: ChangeEvent): void => { const value = event.currentTarget.checked; onChangeValue?.(value); }; diff --git a/apps/meteor/client/views/admin/settings/Setting/inputs/FontSettingInput.tsx b/apps/meteor/client/views/admin/settings/Setting/inputs/FontSettingInput.tsx index 815bc657b71e6..ba65bda228428 100644 --- a/apps/meteor/client/views/admin/settings/Setting/inputs/FontSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/Setting/inputs/FontSettingInput.tsx @@ -1,5 +1,5 @@ import { Field, FieldHint, FieldLabel, FieldRow, TextInput } from '@rocket.chat/fuselage'; -import type { FormEventHandler } from 'react'; +import type { ChangeEventHandler } from 'react'; import ResetSettingButton from '../ResetSettingButton'; import type { SettingInputProps } from './types'; @@ -22,7 +22,7 @@ function FontSettingInput({ onChangeValue, onResetButtonClick, }: FontSettingInputProps) { - const handleChange: FormEventHandler = (event): void => { + const handleChange: ChangeEventHandler = (event): void => { onChangeValue?.(event.currentTarget.value); }; diff --git a/apps/meteor/client/views/admin/settings/Setting/inputs/GenericSettingInput.tsx b/apps/meteor/client/views/admin/settings/Setting/inputs/GenericSettingInput.tsx index 0e5e901255d73..e2e93c8f01a54 100644 --- a/apps/meteor/client/views/admin/settings/Setting/inputs/GenericSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/Setting/inputs/GenericSettingInput.tsx @@ -1,5 +1,5 @@ import { Field, FieldHint, FieldLabel, FieldRow, TextInput } from '@rocket.chat/fuselage'; -import type { FormEventHandler } from 'react'; +import type { ChangeEventHandler } from 'react'; import ResetSettingButton from '../ResetSettingButton'; import type { SettingInputProps } from './types'; @@ -22,7 +22,7 @@ function GenericSettingInput({ onChangeValue, onResetButtonClick, }: GenericSettingInputProps) { - const handleChange: FormEventHandler = (event): void => { + const handleChange: ChangeEventHandler = (event): void => { onChangeValue?.(event.currentTarget.value); }; diff --git a/apps/meteor/client/views/admin/settings/Setting/inputs/IntSettingInput.tsx b/apps/meteor/client/views/admin/settings/Setting/inputs/IntSettingInput.tsx index 7b3747ef3f474..8fda81726d045 100644 --- a/apps/meteor/client/views/admin/settings/Setting/inputs/IntSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/Setting/inputs/IntSettingInput.tsx @@ -1,5 +1,5 @@ import { Field, FieldHint, FieldLabel, FieldRow, InputBox } from '@rocket.chat/fuselage'; -import type { FormEventHandler } from 'react'; +import type { ChangeEventHandler } from 'react'; import ResetSettingButton from '../ResetSettingButton'; import type { SettingInputProps } from './types'; @@ -22,7 +22,7 @@ function IntSettingInput({ hasResetButton, onResetButtonClick, }: IntSettingInputProps) { - const handleChange: FormEventHandler = (event) => { + const handleChange: ChangeEventHandler = (event) => { onChangeValue?.(parseInt(event.currentTarget.value, 10)); }; diff --git a/apps/meteor/client/views/admin/settings/Setting/inputs/PasswordSettingInput.tsx b/apps/meteor/client/views/admin/settings/Setting/inputs/PasswordSettingInput.tsx index c3e98e3fd551e..9bc582bc83855 100644 --- a/apps/meteor/client/views/admin/settings/Setting/inputs/PasswordSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/Setting/inputs/PasswordSettingInput.tsx @@ -1,5 +1,5 @@ import { Field, FieldHint, FieldLabel, FieldRow, PasswordInput } from '@rocket.chat/fuselage'; -import type { EventHandler, SyntheticEvent } from 'react'; +import type { ChangeEventHandler } from 'react'; import ResetSettingButton from '../ResetSettingButton'; import type { SettingInputProps } from './types'; @@ -20,7 +20,7 @@ function PasswordSettingInput({ onChangeValue, onResetButtonClick, }: PasswordSettingInputProps) { - const handleChange: EventHandler> = (event) => { + const handleChange: ChangeEventHandler = (event) => { onChangeValue?.(event.currentTarget.value); }; diff --git a/apps/meteor/client/views/admin/settings/Setting/inputs/RelativeUrlSettingInput.tsx b/apps/meteor/client/views/admin/settings/Setting/inputs/RelativeUrlSettingInput.tsx index bf2eb629b94f3..78e42d2ed0239 100644 --- a/apps/meteor/client/views/admin/settings/Setting/inputs/RelativeUrlSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/Setting/inputs/RelativeUrlSettingInput.tsx @@ -1,6 +1,6 @@ import { Field, FieldHint, FieldLabel, FieldRow, UrlInput } from '@rocket.chat/fuselage'; import { useAbsoluteUrl } from '@rocket.chat/ui-contexts'; -import type { EventHandler, SyntheticEvent } from 'react'; +import type { ChangeEventHandler } from 'react'; import ResetSettingButton from '../ResetSettingButton'; import type { SettingInputProps } from './types'; @@ -23,7 +23,7 @@ function RelativeUrlSettingInput({ }: RelativeUrlSettingInputProps) { const getAbsoluteUrl = useAbsoluteUrl(); - const handleChange: EventHandler> = (event) => { + const handleChange: ChangeEventHandler = (event) => { onChangeValue?.(event.currentTarget.value); }; diff --git a/apps/meteor/client/views/admin/settings/Setting/inputs/StringSettingInput.tsx b/apps/meteor/client/views/admin/settings/Setting/inputs/StringSettingInput.tsx index 8db88c13f514e..2c096cbc4dee7 100644 --- a/apps/meteor/client/views/admin/settings/Setting/inputs/StringSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/Setting/inputs/StringSettingInput.tsx @@ -1,5 +1,5 @@ import { Field, FieldHint, FieldLabel, FieldRow, TextAreaInput, TextInput } from '@rocket.chat/fuselage'; -import type { EventHandler, SyntheticEvent } from 'react'; +import type { ChangeEventHandler } from 'react'; import ResetSettingButton from '../ResetSettingButton'; import type { SettingInputProps } from './types'; @@ -27,7 +27,7 @@ function StringSettingInput({ onChangeValue, onResetButtonClick, }: StringSettingInputProps) { - const handleChange: EventHandler> = (event) => { + const handleChange: ChangeEventHandler = (event) => { onChangeValue?.(event.currentTarget.value); }; diff --git a/apps/meteor/client/views/admin/settings/Setting/inputs/TimespanSettingInput.tsx b/apps/meteor/client/views/admin/settings/Setting/inputs/TimespanSettingInput.tsx index 8ec08a18f22a6..7a65556a4b7b7 100644 --- a/apps/meteor/client/views/admin/settings/Setting/inputs/TimespanSettingInput.tsx +++ b/apps/meteor/client/views/admin/settings/Setting/inputs/TimespanSettingInput.tsx @@ -1,5 +1,5 @@ import { Field, FieldHint, FieldLabel, FieldRow, InputBox, Select } from '@rocket.chat/fuselage'; -import type { FormEventHandler, Key } from 'react'; +import type { ChangeEventHandler, Key } from 'react'; import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -55,7 +55,7 @@ function TimespanSettingInput({ const [timeUnit, setTimeUnit] = useState(getHighestTimeUnit(Number(value))); const [internalValue, setInternalValue] = useState(msToTimeUnit(timeUnit, Number(value))); - const handleChange: FormEventHandler = (event) => { + const handleChange: ChangeEventHandler = (event) => { const newValue = sanitizeInputValue(Number(event.currentTarget.value)); onChangeValue?.(timeUnitToMs(timeUnit, newValue)); diff --git a/apps/meteor/client/views/admin/settings/SettingsGroupPage/SettingsGroupPage.tsx b/apps/meteor/client/views/admin/settings/SettingsGroupPage/SettingsGroupPage.tsx index 10947f5758f28..1941bbb7e3aef 100644 --- a/apps/meteor/client/views/admin/settings/SettingsGroupPage/SettingsGroupPage.tsx +++ b/apps/meteor/client/views/admin/settings/SettingsGroupPage/SettingsGroupPage.tsx @@ -4,7 +4,7 @@ import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; import { Page, PageHeader, PageScrollableContentWithShadow, PageFooter } from '@rocket.chat/ui-client'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useToastMessageDispatch, useSettingsDispatch, useSettings } from '@rocket.chat/ui-contexts'; -import type { ReactNode, FormEvent, MouseEvent } from 'react'; +import type { ReactNode, MouseEvent, FormEvent } from 'react'; import { useMemo, memo } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/apps/meteor/client/views/admin/settings/groups/LDAPGroupPage.tsx b/apps/meteor/client/views/admin/settings/groups/LDAPGroupPage.tsx index e862afaf03ecb..c2253e5564104 100644 --- a/apps/meteor/client/views/admin/settings/groups/LDAPGroupPage.tsx +++ b/apps/meteor/client/views/admin/settings/groups/LDAPGroupPage.tsx @@ -3,7 +3,7 @@ import { Button, Box, TextInput, Field, FieldLabel, FieldRow } from '@rocket.cha import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; import { GenericModal } from '@rocket.chat/ui-client'; import { useSetModal, useToastMessageDispatch, useSetting, useEndpoint } from '@rocket.chat/ui-contexts'; -import type { FormEvent } from 'react'; +import type { ChangeEvent } from 'react'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -53,7 +53,7 @@ function LDAPGroupPage({ _id, i18nLabel, onClickBack, ...group }: LDAPGroupPageP try { await testConnection(); let username = ''; - const handleChangeUsername = (event: FormEvent): void => { + const handleChangeUsername = (event: ChangeEvent): void => { username = event.currentTarget.value; }; @@ -71,7 +71,7 @@ function LDAPGroupPage({ _id, i18nLabel, onClickBack, ...group }: LDAPGroupPageP wrapperFunction={(props) => ( { + onSubmit={(e) => { e.preventDefault(); confirmSearch(); }} diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTableFilters.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTableFilters.tsx index 42e007de6f508..e43bc1a6c73d4 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTableFilters.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTableFilters.tsx @@ -3,7 +3,7 @@ import { Box, Icon, Margins, TextInput } from '@rocket.chat/fuselage'; import { useBreakpoints } from '@rocket.chat/fuselage-hooks'; import type { OptionProp } from '@rocket.chat/ui-client'; import { MultiSelectCustom } from '@rocket.chat/ui-client'; -import type { Dispatch, FormEvent, SetStateAction } from 'react'; +import type { ChangeEvent, Dispatch, SetStateAction } from 'react'; import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -21,7 +21,7 @@ const UsersTableFilters = ({ roleData, setUsersFilters }: UsersTableFiltersProps const [text, setText] = useState(''); const handleSearchTextChange = useCallback( - (event: FormEvent) => { + (event: ChangeEvent) => { setText(event.currentTarget.value); setUsersFilters({ text: event.currentTarget.value, roles: selectedRoles }); }, @@ -66,7 +66,7 @@ const UsersTableFilters = ({ roleData, setUsersFilters }: UsersTableFiltersProps ) => { + onSubmit={(event) => { event.preventDefault(); }} display='flex' diff --git a/apps/meteor/client/views/audit/components/forms/DateRangePicker.tsx b/apps/meteor/client/views/audit/components/forms/DateRangePicker.tsx index da65923a97234..80a3cc33343e0 100644 --- a/apps/meteor/client/views/audit/components/forms/DateRangePicker.tsx +++ b/apps/meteor/client/views/audit/components/forms/DateRangePicker.tsx @@ -2,7 +2,7 @@ import { Box, InputBox, Margins } from '@rocket.chat/fuselage'; import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; import { GenericMenu } from '@rocket.chat/ui-client'; import { startOfDay, endOfDay, startOfWeek, endOfWeek, startOfMonth, endOfMonth, subDays, subWeeks, subMonths, parseISO } from 'date-fns'; -import type { ComponentProps, SetStateAction, FormEvent } from 'react'; +import type { ComponentProps, SetStateAction, ChangeEvent } from 'react'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -130,11 +130,11 @@ const DateRangePicker = ({ value, onChange, ...props }: DateRangePickerProps) => onChange?.(newRange); }); - const handleChangeStart = useEffectEvent(({ currentTarget }: FormEvent) => { + const handleChangeStart = useEffectEvent(({ currentTarget }: ChangeEvent) => { dispatch({ newStart: currentTarget.value }); }); - const handleChangeEnd = useEffectEvent(({ currentTarget }: FormEvent) => { + const handleChangeEnd = useEffectEvent(({ currentTarget }: ChangeEvent) => { dispatch({ newEnd: currentTarget.value }); }); diff --git a/apps/meteor/client/views/mediaCallHistory/CallHistoryPageFilters.tsx b/apps/meteor/client/views/mediaCallHistory/CallHistoryPageFilters.tsx index 56c4942227169..e1f333c571db0 100644 --- a/apps/meteor/client/views/mediaCallHistory/CallHistoryPageFilters.tsx +++ b/apps/meteor/client/views/mediaCallHistory/CallHistoryPageFilters.tsx @@ -2,7 +2,7 @@ import { Box, Icon, TextInput, Select } from '@rocket.chat/fuselage'; import type { OptionProp } from '@rocket.chat/ui-client'; import { MultiSelectCustom } from '@rocket.chat/ui-client'; import { useCallback, useMemo, useState } from 'react'; -import type { FormEvent, Key } from 'react'; +import type { ChangeEvent, Key, FormEvent } from 'react'; import { useTranslation } from 'react-i18next'; type StatesFilter = Array<'ended' | 'transferred' | 'not-answered' | 'failed'>; @@ -79,7 +79,7 @@ const CallHistoryPageFilters = ({ onChangeText, onChangeType, onChangeStates, se return ( e.preventDefault(), [])} + onSubmit={useCallback((e: FormEvent) => e.preventDefault(), [])} mb='x8' display='flex' flexWrap='wrap' @@ -92,7 +92,7 @@ const CallHistoryPageFilters = ({ onChangeText, onChangeType, onChangeStates, se alignItems='center' placeholder={t('Search_calls')} addon={} - onChange={(e: FormEvent) => onChangeText(e.currentTarget.value)} + onChange={(e: ChangeEvent) => onChangeText(e.currentTarget.value)} value={searchText} /> diff --git a/apps/meteor/client/views/omnichannel/analytics/DateRangePicker.tsx b/apps/meteor/client/views/omnichannel/analytics/DateRangePicker.tsx index 746225ee2f44a..e8307eae49b3a 100644 --- a/apps/meteor/client/views/omnichannel/analytics/DateRangePicker.tsx +++ b/apps/meteor/client/views/omnichannel/analytics/DateRangePicker.tsx @@ -2,7 +2,7 @@ import { Box, InputBox, Field, FieldLabel, FieldRow } from '@rocket.chat/fuselag import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; import { GenericMenu } from '@rocket.chat/ui-client'; import { subDays, subMonths, startOfMonth, endOfMonth, format } from 'date-fns'; -import type { ComponentProps, FormEvent } from 'react'; +import type { ComponentProps, ChangeEvent } from 'react'; import { useState, useMemo, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; @@ -39,7 +39,7 @@ const DateRangePicker = ({ onChange = () => undefined, ...props }: DateRangePick const { start, end } = range; - const handleStart = useEffectEvent(({ currentTarget }: FormEvent) => { + const handleStart = useEffectEvent(({ currentTarget }: ChangeEvent) => { const rangeObj = { start: currentTarget.value, end: range.end, @@ -48,7 +48,7 @@ const DateRangePicker = ({ onChange = () => undefined, ...props }: DateRangePick onChange(rangeObj); }); - const handleEnd = useEffectEvent(({ currentTarget }: FormEvent) => { + const handleEnd = useEffectEvent(({ currentTarget }: ChangeEvent) => { const rangeObj = { end: currentTarget.value, start: range.start, diff --git a/apps/meteor/client/views/omnichannel/cannedResponses/contextualBar/CannedResponse/CannedResponseList.tsx b/apps/meteor/client/views/omnichannel/cannedResponses/contextualBar/CannedResponse/CannedResponseList.tsx index 516a70ee0143d..041952b2e5584 100644 --- a/apps/meteor/client/views/omnichannel/cannedResponses/contextualBar/CannedResponse/CannedResponseList.tsx +++ b/apps/meteor/client/views/omnichannel/cannedResponses/contextualBar/CannedResponse/CannedResponseList.tsx @@ -11,7 +11,7 @@ import { ContextualbarDialog, } from '@rocket.chat/ui-client'; import { useRoomToolbox } from '@rocket.chat/ui-contexts'; -import type { Dispatch, FormEventHandler, MouseEvent, SetStateAction } from 'react'; +import type { Dispatch, ChangeEventHandler, MouseEvent, SetStateAction } from 'react'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { Virtuoso } from 'react-virtuoso'; @@ -27,7 +27,7 @@ type CannedResponseListProps = { onClose: () => void; options: [string, string][]; text: string; - setText: FormEventHandler; + setText: ChangeEventHandler; type: string; setType: Dispatch>; isRoomOverMacLimit: boolean; diff --git a/apps/meteor/client/views/omnichannel/departments/DepartmentTags.tsx b/apps/meteor/client/views/omnichannel/departments/DepartmentTags.tsx index 36815bde66c91..f8854c6575791 100644 --- a/apps/meteor/client/views/omnichannel/departments/DepartmentTags.tsx +++ b/apps/meteor/client/views/omnichannel/departments/DepartmentTags.tsx @@ -1,5 +1,5 @@ import { Button, Chip, FieldRow, TextInput } from '@rocket.chat/fuselage'; -import type { ComponentProps, FormEvent } from 'react'; +import type { ComponentProps, ChangeEvent } from 'react'; import { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -33,7 +33,7 @@ const DepartmentTags = ({ error, value: tags, onChange, ...props }: DepartmentTa error={error} placeholder={t('Enter_a_tag')} value={tagText} - onChange={(e: FormEvent) => setTagText(e.currentTarget.value)} + onChange={(e: ChangeEvent) => setTagText(e.currentTarget.value)} {...props} />