snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Code scanning - action / CodeQL-Build (push) Failing after 2s
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,487 @@
# @rocket.chat/abac
## 0.3.2
### Patch Changes
- ([#41763](https://github.com/RocketChat/Rocket.Chat/pull/41763)) Drops `@rocket.chat/string-helpers` in favor of `@rocket.chat/tools`
- <details><summary>Updated dependencies [4947601bbf042cd1b2385f8f5dda438e608faea7, 0869925e52ca61a440a01a6646935b89af8c7aae, b89a8d411ef65f6931a5fd1cd057740bc00cd9ba, 5deefe2766132f13118f38dc42cf1c9895208961, 8984df841e1ecc46198e073982adabc1f069c352, a9f3e6cd3f31eac17bec626287fcf815d802e9b9, 742009a09148e33d141f50691cdec0f7e9818535]:</summary>
- @rocket.chat/core-typings@8.8.0
- @rocket.chat/models@2.5.0
- @rocket.chat/core-services@0.16.0
- @rocket.chat/tools@0.4.0
- @rocket.chat/server-fetch@0.2.4
</details>
## 0.3.2-rc.0
### Patch Changes
- ([#41763](https://github.com/RocketChat/Rocket.Chat/pull/41763)) Drops `@rocket.chat/string-helpers` in favor of `@rocket.chat/tools`
- <details><summary>Updated dependencies [4947601bbf042cd1b2385f8f5dda438e608faea7, 0869925e52ca61a440a01a6646935b89af8c7aae, b89a8d411ef65f6931a5fd1cd057740bc00cd9ba, 5deefe2766132f13118f38dc42cf1c9895208961, 8984df841e1ecc46198e073982adabc1f069c352, a9f3e6cd3f31eac17bec626287fcf815d802e9b9, 742009a09148e33d141f50691cdec0f7e9818535]:</summary>
- @rocket.chat/core-typings@8.8.0-rc.0
- @rocket.chat/models@2.5.0-rc.0
- @rocket.chat/core-services@0.16.0-rc.0
- @rocket.chat/tools@0.4.0-rc.0
- @rocket.chat/server-fetch@0.2.4-rc.0
</details>
## 0.3.1
### Patch Changes
- <details><summary>Updated dependencies [c7aff48a40a9a78924cbf27fd38930c536ee11e5, 5f92f9a27dca70d506d919351612bd32dc04241a, 13b4a7b2dc203959b77b3b0c5f154d3e34fe2058, 4b34bd62f2ac8d51efd2f48caea7092e87f30ce7, 1bf84cbe288df03fc622fbddbc0e434bda291c2f, 4b57346a59b5c9433c25845c886be11af1bf71d4, 8d8cd01d0a4e6872ed543320c966efd52140e884, ffe1b646226eeeda5a4d4697c831e568ec1eec64, 3cd7db677a72521439b564dca7a4ca6d6c3a1c07, 719e3db9734708e812ceb33483ffaa2e064b4d59, 615ae2bf74bba0402e0151d9c0b8e4f8dd04cb17, 1cc7bbdef9330899a8207d3d55130f48321d68f1]:</summary>
- @rocket.chat/core-typings@8.7.0
- @rocket.chat/models@2.4.0
- @rocket.chat/core-services@0.15.0
- @rocket.chat/server-fetch@0.2.3
</details>
## 0.3.1-rc.0
### Patch Changes
- <details><summary>Updated dependencies [c7aff48a40a9a78924cbf27fd38930c536ee11e5, 5f92f9a27dca70d506d919351612bd32dc04241a, 13b4a7b2dc203959b77b3b0c5f154d3e34fe2058, 4b34bd62f2ac8d51efd2f48caea7092e87f30ce7, 1bf84cbe288df03fc622fbddbc0e434bda291c2f, 4b57346a59b5c9433c25845c886be11af1bf71d4, 8d8cd01d0a4e6872ed543320c966efd52140e884, ffe1b646226eeeda5a4d4697c831e568ec1eec64, 3cd7db677a72521439b564dca7a4ca6d6c3a1c07, 719e3db9734708e812ceb33483ffaa2e064b4d59, 615ae2bf74bba0402e0151d9c0b8e4f8dd04cb17, 1cc7bbdef9330899a8207d3d55130f48321d68f1]:</summary>
- @rocket.chat/core-typings@8.7.0-rc.0
- @rocket.chat/models@2.4.0-rc.0
- @rocket.chat/core-services@0.15.0-rc.0
- @rocket.chat/server-fetch@0.2.3-rc.0
</details>
## 0.3.0
### Minor Changes
- ([#40634](https://github.com/RocketChat/Rocket.Chat/pull/40634)) Allows using Virtru as the attribute store for ABAC decisions.
### Important
- When using virtru as the store, the internal attribute store is disabled.
- On switch, existing ABAC attributes from rooms will be removed. Rooms will continue to be private & no users will be removed until you add attributes again.
- Users are only allowed to see & edit rooms they have access to. Access decision is evaluated on Virtru
- A user/app with the `bypass-abac-store-validation` permission can assign any attributes to rooms, even if the user doesn't have them assigned on Virtru.
### Patch Changes
- <details><summary>Updated dependencies [73e12e1707baea845395e0582892f65456598672, a7279cebc73edfa4b991eb593730c08e8f5e9001, 7380c44c751eff9ee624d80bf26370411ffed78b, 6bd9182ae1d914a55e70866db43e8d2038f7be28, 6fa5378a940cbc809800b3c7d7c0639810bb0ab8, f63b965f82b0ddc590c633706f7c31c8c5251b53]:</summary>
- @rocket.chat/models@2.3.1
- @rocket.chat/core-services@0.14.2
- @rocket.chat/core-typings@8.6.0
- @rocket.chat/server-fetch@0.2.2
</details>
## 0.3.0-rc.0
### Minor Changes
- ([#40634](https://github.com/RocketChat/Rocket.Chat/pull/40634)) Allows using Virtru as the attribute store for ABAC decisions.
### Important
- When using virtru as the store, the internal attribute store is disabled.
- On switch, existing ABAC attributes from rooms will be removed. Rooms will continue to be private & no users will be removed until you add attributes again.
- Users are only allowed to see & edit rooms they have access to. Access decision is evaluated on Virtru
- A user/app with the `bypass-abac-store-validation` permission can assign any attributes to rooms, even if the user doesn't have them assigned on Virtru.
### Patch Changes
- <details><summary>Updated dependencies [73e12e1707baea845395e0582892f65456598672, a7279cebc73edfa4b991eb593730c08e8f5e9001, 7380c44c751eff9ee624d80bf26370411ffed78b, 6bd9182ae1d914a55e70866db43e8d2038f7be28, f63b965f82b0ddc590c633706f7c31c8c5251b53]:</summary>
- @rocket.chat/models@2.3.1-rc.0
- @rocket.chat/core-services@0.14.2-rc.0
- @rocket.chat/core-typings@8.6.0-rc.0
- @rocket.chat/server-fetch@0.2.2-rc.0
</details>
## 0.2.1
### Patch Changes
- ([#40499](https://github.com/RocketChat/Rocket.Chat/pull/40499)) Fixes an issue where some actions made by the abac service were not broadcasting to clients, which affected reactivity
- <details><summary>Updated dependencies [f7d47dd3517ec14ca2ec5c3c95fcdf9e1e2fb8b0, b6b04aadfcc8558f888b334e37c46a77e5816237, 4704bf81ca370f120af32185a7c55407a26f8514, 12897e25d0dc25b7373f5264d38f38a5a7444257, e45585b70a3a7b75434c88e4b2ea9af0a0764a76]:</summary>
- @rocket.chat/models@2.3.0
- @rocket.chat/core-typings@8.5.0
- @rocket.chat/core-services@0.14.1
- @rocket.chat/server-fetch@0.2.1
</details>
## 0.2.1-rc.0
### Patch Changes
- ([#40499](https://github.com/RocketChat/Rocket.Chat/pull/40499)) Fixes an issue where some actions made by the abac service were not broadcasting to clients, which affected reactivity
- <details><summary>Updated dependencies [f7d47dd3517ec14ca2ec5c3c95fcdf9e1e2fb8b0, ae9f740d6af20557eac61b4af902c868b4132b49, b6b04aadfcc8558f888b334e37c46a77e5816237, 4704bf81ca370f120af32185a7c55407a26f8514, 12897e25d0dc25b7373f5264d38f38a5a7444257, e45585b70a3a7b75434c88e4b2ea9af0a0764a76]:</summary>
- @rocket.chat/models@2.3.0-rc.0
- @rocket.chat/core-typings@8.5.0-rc.0
- @rocket.chat/core-services@0.14.1-rc.0
- @rocket.chat/server-fetch@0.2.1-rc.0
</details>
## 0.2.0
### Minor Changes
- ([#39845](https://github.com/RocketChat/Rocket.Chat/pull/39845)) Adds support for setting up Virtru as a PDP (Policy Decision Point) for ABAC.
### Patch Changes
- <details><summary>Updated dependencies [21cd54f87de5837fe6c999a44bd15be34b9fe905, 278b84f78360e53792a2e5d7620615039a0e15e9, 95a82f72dd45fc51d54bb1beed295315facf9109, 12c44d2db65af9f90c741da621164f5738fc19f5, 24b3671fe61b8b09c6a1b5dc6401b503b3fb92a0, 9713af36f5c0d673f2d2093015f322341706bab0, f3fa3ee2f2e53b777de9abd466f1b76a1ec1b96c, c544b805d1c03f8eae9d061cd48838206207a7c9, 8c0e16ca29b393cfa50b425520db48ba5a74f678, 95a82f72dd45fc51d54bb1beed295315facf9109, f4dfb8ddc2049692371aeb084110b5768151b5df]:</summary>
- @rocket.chat/core-services@0.14.0
- @rocket.chat/models@2.2.0
- @rocket.chat/core-typings@8.4.0
- @rocket.chat/server-fetch@0.2.0
- @rocket.chat/tools@0.3.0
</details>
## 0.2.0-rc.2
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.4.0-rc.2
- @rocket.chat/core-services@0.14.0-rc.2
- @rocket.chat/models@2.2.0-rc.2
- @rocket.chat/server-fetch@0.2.0-rc.2
</details>
## 0.2.0-rc.1
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-services@0.14.0-rc.1
- @rocket.chat/core-typings@8.4.0-rc.1
- @rocket.chat/models@2.2.0-rc.1
- @rocket.chat/server-fetch@0.2.0-rc.1
</details>
## 0.1.8
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-services@0.13.4
- @rocket.chat/core-typings@8.3.2
- @rocket.chat/models@2.1.4
</details>
## 0.1.7
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.3.1
- @rocket.chat/core-services@0.13.3
- @rocket.chat/models@2.1.3
</details>
## 0.2.0-rc.0
### Minor Changes
- ([#39845](https://github.com/RocketChat/Rocket.Chat/pull/39845)) Adds support for setting up Virtru as a PDP (Policy Decision Point) for ABAC.
### Patch Changes
- <details><summary>Updated dependencies [21cd54f87de5837fe6c999a44bd15be34b9fe905, 278b84f78360e53792a2e5d7620615039a0e15e9, 95a82f72dd45fc51d54bb1beed295315facf9109, 12c44d2db65af9f90c741da621164f5738fc19f5, 24b3671fe61b8b09c6a1b5dc6401b503b3fb92a0, 9713af36f5c0d673f2d2093015f322341706bab0, f3fa3ee2f2e53b777de9abd466f1b76a1ec1b96c, c544b805d1c03f8eae9d061cd48838206207a7c9, 8c0e16ca29b393cfa50b425520db48ba5a74f678, 95a82f72dd45fc51d54bb1beed295315facf9109, f4dfb8ddc2049692371aeb084110b5768151b5df]:</summary>
- @rocket.chat/core-services@0.14.0-rc.0
- @rocket.chat/models@2.2.0-rc.0
- @rocket.chat/core-typings@8.4.0-rc.0
- @rocket.chat/server-fetch@0.2.0-rc.0
- @rocket.chat/tools@0.3.0-rc.0
</details>
## 0.1.6
### Patch Changes
- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration
- <details><summary>Updated dependencies [602b20a8c570b895eb296ecfe39c9b7fcb12fabd, e2068892bf1ffc88b15ab71ad743cf84e5d31ed5, cd2fc208d351032c0b729755af4886665dca08b6, 539659af22bc19880eda047dfc0b152472ccb65c, 78e37dc3deae4ff05f5e33f9134c7094fd6c1330, d83a1a9753464ee916845b3c88757bbcf76884a5, eae3fb3136bd0b48294c050a71b0a36d05ca02b0, 722df6f60bc86c51b204e28a39acb3dc8710bdeb, c117492ad90d291a361eedc929506f557495caf7]:</summary>
- @rocket.chat/models@2.1.2
- @rocket.chat/core-services@0.13.2
- @rocket.chat/core-typings@8.3.0
- @rocket.chat/logger@1.0.1
</details>
## 0.1.6-rc.4
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.3.0-rc.4
- @rocket.chat/core-services@0.13.2-rc.4
- @rocket.chat/models@2.1.2-rc.4
</details>
## 0.1.6-rc.3
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.3.0-rc.3
- @rocket.chat/core-services@0.13.2-rc.3
- @rocket.chat/models@2.1.2-rc.3
</details>
## 0.1.6-rc.2
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.3.0-rc.2
- @rocket.chat/core-services@0.13.2-rc.2
- @rocket.chat/models@2.1.2-rc.2
</details>
## 0.1.6-rc.1
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.3.0-rc.1
- @rocket.chat/core-services@0.13.2-rc.1
- @rocket.chat/models@2.1.2-rc.1
</details>
## 0.1.6-rc.0
### Patch Changes
- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration
- <details><summary>Updated dependencies [602b20a8c570b895eb296ecfe39c9b7fcb12fabd, e2068892bf1ffc88b15ab71ad743cf84e5d31ed5, cd2fc208d351032c0b729755af4886665dca08b6, 539659af22bc19880eda047dfc0b152472ccb65c, 78e37dc3deae4ff05f5e33f9134c7094fd6c1330, d83a1a9753464ee916845b3c88757bbcf76884a5, eae3fb3136bd0b48294c050a71b0a36d05ca02b0, 722df6f60bc86c51b204e28a39acb3dc8710bdeb, c117492ad90d291a361eedc929506f557495caf7]:</summary>
- @rocket.chat/models@2.1.2-rc.0
- @rocket.chat/core-services@0.13.2-rc.0
- @rocket.chat/core-typings@8.3.0-rc.0
- @rocket.chat/logger@1.0.1-rc.0
</details>
## 0.1.5
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.2.1
- @rocket.chat/core-services@0.13.1
- @rocket.chat/models@2.1.1
</details>
## 0.1.4
### Patch Changes
- <details><summary>Updated dependencies [d3758a7d57ab602745369ef9d2ccdbf9271cf305, fbc4935dec220495201cf905017170d3cd1e275c, e57f15845e4df048dd2f08f11aa08215780a2c34, 11e1c51f0867a35c69ce9b6eeca25dbbe2c71872, 3b003e6b69c11b280d55bcc8db2f3e4ae7a4a573, 87faec13b3c0efc3e85627f9b70c4561b7231416, 508b4a17d76dc1cd7d3a55bdba826216f51432e2, 123aebec2caa74b17d2b5dcbd2a2db2e687cf3ac]:</summary>
- @rocket.chat/core-typings@8.2.0
- @rocket.chat/models@2.1.0
- @rocket.chat/core-services@0.13.0
</details>
## 0.1.4-rc.2
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.2.0-rc.2
- @rocket.chat/core-services@0.13.0-rc.2
- @rocket.chat/models@2.1.0-rc.2
</details>
## 0.1.4-rc.1
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.2.0-rc.1
- @rocket.chat/core-services@0.13.0-rc.1
- @rocket.chat/models@2.1.0-rc.1
</details>
## 0.1.4-rc.0
### Patch Changes
- <details><summary>Updated dependencies [d3758a7d57ab602745369ef9d2ccdbf9271cf305, fbc4935dec220495201cf905017170d3cd1e275c, e57f15845e4df048dd2f08f11aa08215780a2c34, 11e1c51f0867a35c69ce9b6eeca25dbbe2c71872, 3b003e6b69c11b280d55bcc8db2f3e4ae7a4a573, 87faec13b3c0efc3e85627f9b70c4561b7231416, 508b4a17d76dc1cd7d3a55bdba826216f51432e2, 123aebec2caa74b17d2b5dcbd2a2db2e687cf3ac]:</summary>
- @rocket.chat/core-typings@8.2.0-rc.0
- @rocket.chat/models@2.1.0-rc.0
- @rocket.chat/core-services@0.13.0-rc.0
</details>
## 0.1.3
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.1.1
- @rocket.chat/core-services@0.12.3
- @rocket.chat/models@2.0.3
</details>
## 0.1.2
### Patch Changes
- <details><summary>Updated dependencies [f4fa393fccb6abefbe6fb6550563e8ca21fc792d, 7b51ba84340480fa28e85c8f35d9d2f947d3250e, 6654c5b481f91bdcb03d68ee0f3a12d58201137e, 020dfbcab6a940ca2e03d802d0f6b54714a34aa2, 5fa150953b86ff36face25083ed49e8c97a8044d, 05c415b94cb91907de39a39c6d277579258f334e]:</summary>
- @rocket.chat/models@2.0.2
- @rocket.chat/core-services@0.12.2
- @rocket.chat/core-typings@8.1.0
</details>
## 0.1.2-rc.2
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-services@0.12.2-rc.2
- @rocket.chat/core-typings@8.1.0-rc.2
- @rocket.chat/models@2.0.2-rc.2
</details>
## 0.1.2-rc.1
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-services@0.12.1-rc.1
- @rocket.chat/models@2.0.1-rc.1
- @rocket.chat/core-typings@8.1.0-rc.1
</details>
## 0.1.2-rc.0
### Patch Changes
- <details><summary>Updated dependencies [f4fa393fccb6abefbe6fb6550563e8ca21fc792d, 7b51ba84340480fa28e85c8f35d9d2f947d3250e, 6654c5b481f91bdcb03d68ee0f3a12d58201137e, 020dfbcab6a940ca2e03d802d0f6b54714a34aa2, 5fa150953b86ff36face25083ed49e8c97a8044d, 05c415b94cb91907de39a39c6d277579258f334e]:</summary>
- @rocket.chat/models@2.0.1-rc.0
- @rocket.chat/core-services@0.12.1-rc.0
- @rocket.chat/core-typings@8.1.0-rc.0
</details>
## 0.1.1
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.0.1
- @rocket.chat/core-services@0.12.1
- @rocket.chat/models@2.0.1
</details>
## 0.1.0
### Minor Changes
- ([#37091](https://github.com/RocketChat/Rocket.Chat/pull/37091)) Adds Attribute Based Access Control (ABAC) for private channels & private teams.
### Patch Changes
- <details><summary>Updated dependencies [176d5eae3fb249d7d20c3e260d9fadc1a56a2fca, ac11ea05ffadeca978c794ff38d5199d9acb2c29, 1baa03cced8f94584da1224ad59cad86f219707a, ac11ea05ffadeca978c794ff38d5199d9acb2c29, ddc935727e9a7275813006d9dcaa7fe866610844, 73d9eb2783176954f42aa2cbeda8abf1d49ac260, be80b724a636877294b5e5baa501d070941131dd, 4aa3634186d97f4144c39f6b42a65107d3d30df0, ec0f8b435dd12c218adffa8892737c7ced4debb8]:</summary>
- @rocket.chat/core-typings@8.0.0
- @rocket.chat/models@2.0.0
- @rocket.chat/core-services@0.12.0
- @rocket.chat/logger@1.0.0
</details>
## 0.1.0-rc.5
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.0.0-rc.5
- @rocket.chat/core-services@0.12.0-rc.5
- @rocket.chat/models@2.0.0-rc.5
</details>
## 0.1.0-rc.4
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.0.0-rc.4
- @rocket.chat/core-services@0.12.0-rc.4
- @rocket.chat/models@2.0.0-rc.4
</details>
## 0.1.0-rc.3
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-services@0.12.0-rc.3
- @rocket.chat/core-typings@8.0.0-rc.3
- @rocket.chat/models@2.0.0-rc.3
</details>
## 0.1.0-rc.2
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.0.0-rc.2
- @rocket.chat/core-services@0.12.0-rc.2
- @rocket.chat/models@2.0.0-rc.2
</details>
## 0.1.0-rc.1
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.0.0-rc.1
- @rocket.chat/core-services@0.12.0-rc.1
- @rocket.chat/models@2.0.0-rc.1
</details>
## 0.1.0-rc.0
### Minor Changes
- ([#37091](https://github.com/RocketChat/Rocket.Chat/pull/37091)) Adds Attribute Based Access Control (ABAC) for private channels & private teams.
### Patch Changes
- <details><summary>Updated dependencies [176d5eae3fb249d7d20c3e260d9fadc1a56a2fca, ac11ea05ffadeca978c794ff38d5199d9acb2c29, 1baa03cced8f94584da1224ad59cad86f219707a, ac11ea05ffadeca978c794ff38d5199d9acb2c29, ddc935727e9a7275813006d9dcaa7fe866610844, 73d9eb2783176954f42aa2cbeda8abf1d49ac260, be80b724a636877294b5e5baa501d070941131dd, 4aa3634186d97f4144c39f6b42a65107d3d30df0, ec0f8b435dd12c218adffa8892737c7ced4debb8]:</summary>
- @rocket.chat/core-typings@8.0.0-rc.0
- @rocket.chat/models@2.0.0-rc.0
- @rocket.chat/core-services@0.12.0-rc.0
- @rocket.chat/logger@1.0.0-rc.0
</details>

View File

@@ -0,0 +1,93 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://rocket.chat/schemas/classification-banners/v1.json",
"title": "Rocket.Chat Classification Banners configuration",
"type": "object",
"required": ["version", "enabled", "banner", "attributes"],
"additionalProperties": false,
"properties": {
"$schema": { "type": "string" },
"version": { "const": 1 },
"enabled": { "type": "boolean" },
"banner": {
"type": "object",
"required": ["style", "uppercase", "monospace", "delimiter", "colorMode", "fallbackText", "fallbackColor"],
"additionalProperties": false,
"properties": {
"style": { "enum": ["classic"] },
"uppercase": { "type": "boolean" },
"monospace": { "type": "boolean" },
"delimiter": { "type": "string", "minLength": 1, "maxLength": 8 },
"colorMode": { "enum": ["highest", "attribute"] },
"fallbackText": { "type": "string", "minLength": 1 },
"fallbackColor": { "$ref": "#/$defs/hexColor" }
}
},
"attributes": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"description": "Attribute ids must be unique across entries (uniqueness by id is not expressible in JSON Schema; validated by tooling)",
"items": { "$ref": "#/$defs/attribute" }
}
},
"$defs": {
"hexColor": {
"type": "string",
"pattern": "^#[0-9a-fA-F]{6}$"
},
"attribute": {
"type": "object",
"required": [
"id",
"source",
"label",
"showInBanner",
"showLabel",
"bannerLabel",
"labelSeparator",
"valueSeparator",
"sortAlpha",
"groupThreshold",
"multipleLabel",
"drivesColor",
"values"
],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "pattern": "^[a-z][a-z0-9_-]*$" },
"source": { "type": "string", "minLength": 1, "description": "Raw attribute key from the IdP (SAML/LDAP)" },
"label": { "type": "string", "minLength": 1, "description": "Friendly admin-facing name" },
"showInBanner": { "type": "boolean" },
"showLabel": { "type": "boolean" },
"bannerLabel": { "type": "string" },
"labelSeparator": { "enum": ["-", " ", ""] },
"valueSeparator": { "enum": ["/", ", ", " "] },
"sortAlpha": { "type": "boolean" },
"groupThreshold": {
"type": "integer",
"description": "0 = never collapse; otherwise collapse when value count >= threshold (2-20)",
"oneOf": [{ "const": 0 }, { "minimum": 2, "maximum": 20 }]
},
"multipleLabel": { "type": "string" },
"drivesColor": { "type": "boolean" },
"values": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"description": "Value sources must be unique within the attribute (uniqueness by source is not expressible in JSON Schema; validated by tooling)",
"items": {
"type": "object",
"required": ["source", "label", "color"],
"additionalProperties": false,
"properties": {
"source": { "type": "string", "minLength": 1 },
"label": { "type": "string", "minLength": 1 },
"color": { "$ref": "#/$defs/hexColor" }
}
}
}
}
}
}
}

View File

@@ -0,0 +1,7 @@
import server from '@rocket.chat/jest-presets/server';
import type { Config } from 'jest';
export default {
preset: server.preset,
testMatch: ['<rootDir>/src/**/*.spec.(ts|js|mjs)'],
} satisfies Config;

View File

@@ -0,0 +1,49 @@
{
"name": "@rocket.chat/abac",
"version": "0.3.2",
"private": true,
"description": "Rocket.Chat - Attribute Based Access Control (ABAC) support utilities",
"main": "./dist/index.js",
"typings": "./dist/index.d.ts",
"files": [
"/dist"
],
"scripts": {
"build": "tsc -p tsconfig.build.json",
"dev": "tsc --watch --preserveWatchOutput",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"test": "jest",
"testunit": "jest",
"typecheck": "tsc --noEmit --skipLibCheck"
},
"config": {
"mongodbMemoryServer": {
"downloadDir": "../../../node_modules/.cache/mongodb-memory-server",
"preferGlobalPath": false
}
},
"volta": {
"extends": "../../../package.json"
},
"dependencies": {
"@rocket.chat/core-services": "workspace:^",
"@rocket.chat/core-typings": "workspace:^",
"@rocket.chat/logger": "workspace:^",
"@rocket.chat/models": "workspace:^",
"@rocket.chat/server-fetch": "workspace:^",
"@rocket.chat/tools": "workspace:^",
"mem": "^8.1.1",
"mongodb": "6.10.0",
"p-limit": "3.1.0"
},
"devDependencies": {
"@rocket.chat/tsconfig": "workspace:*",
"@types/jest": "~30.0.0",
"@types/node": "~24.13.2",
"eslint": "~9.39.5",
"jest": "~30.2.0",
"mongodb-memory-server": "^10.2.3",
"typescript": "~5.9.3"
}
}

View File

@@ -0,0 +1,155 @@
import type { AbacActor } from '@rocket.chat/core-services';
import type {
ExtractDataToParams,
IAbacAttributeDefinition,
IAuditServerActor,
IServerEvents,
AbacAuditServerEventKey,
AbacAttributeDefinitionChangeType,
AbacAuditReason,
AbacAttributeStoreType,
AbacPdpType,
MinimalRoom,
MinimalUser,
AbacActionPerformed,
} from '@rocket.chat/core-typings';
import { ServerEvents } from '@rocket.chat/models';
type EventParamsMap = {
[K in AbacAuditServerEventKey]: ExtractDataToParams<IServerEvents[K]>;
};
type EventPayload<K extends AbacAuditServerEventKey> = EventParamsMap[K];
export type AbacAuditEventName = AbacAuditServerEventKey;
export type AbacAuditEventPayload<K extends AbacAuditEventName = AbacAuditEventName> = EventPayload<K>;
async function audit<K extends AbacAuditServerEventKey>(event: K, payload: EventPayload<K>, actor: IAuditServerActor): Promise<void> {
return ServerEvents.createAuditServerEvent(event, payload, actor);
}
export const Audit = {
attributeCreated: async (attribute: IAbacAttributeDefinition, actor: AbacActor) => {
return audit(
'abac.attribute.changed',
{
attributeKey: attribute.key,
reason: 'api',
change: 'created',
current: attribute,
} as EventPayload<'abac.attribute.changed'>,
{ type: 'user', _id: actor._id, username: actor.username!, ip: '0.0.0.0', useragent: '' },
);
},
attributeUpdated: async (current: IAbacAttributeDefinition, diff: IAbacAttributeDefinition, actor: AbacActor) => {
return audit(
'abac.attribute.changed',
{
attributeKey: current.key,
reason: 'api',
change: 'updated',
current,
diff,
},
{ type: 'user', _id: actor._id, username: actor.username!, ip: '0.0.0.0', useragent: '' },
);
},
attributeDeleted: async (attribute: IAbacAttributeDefinition, actor: AbacActor) => {
return audit(
'abac.attribute.changed',
{
attributeKey: attribute.key,
reason: 'api',
change: 'deleted',
current: null,
diff: attribute,
},
{ type: 'user', _id: actor._id, username: actor.username!, ip: '0.0.0.0', useragent: '' },
);
},
objectAttributeChanged: async (
minimalRoom: MinimalRoom,
previous: IAbacAttributeDefinition[],
current: IAbacAttributeDefinition[],
change: AbacAttributeDefinitionChangeType,
actor: AbacActor,
) => {
return audit(
'abac.object.attribute.changed',
{
room: minimalRoom,
reason: 'api',
change,
previous,
current,
},
{ type: 'user', _id: actor._id, username: actor.username!, ip: '0.0.0.0', useragent: '' },
);
},
objectAttributeRemoved: async (
minimalRoom: MinimalRoom,
previous: IAbacAttributeDefinition[],
current: IAbacAttributeDefinition[],
change: AbacAttributeDefinitionChangeType,
actor: AbacActor,
) => {
return audit(
'abac.object.attribute.changed',
{
room: minimalRoom,
reason: 'api',
change,
previous,
current,
},
{ type: 'user', _id: actor._id, username: actor.username!, ip: '0.0.0.0', useragent: '' },
);
},
objectAttributesRemoved: async (minimalRoom: MinimalRoom, previous: IAbacAttributeDefinition[], actor: AbacActor) => {
return audit(
'abac.object.attributes.removed',
{
room: minimalRoom,
reason: 'api',
change: 'all-deleted',
previous,
current: null,
},
{ type: 'user', _id: actor._id, username: actor.username!, ip: '0.0.0.0', useragent: '' },
);
},
actionPerformed: async (
subject: MinimalUser,
object: MinimalRoom,
reason: AbacAuditReason = 'room-attributes-change',
actionPerformed: AbacActionPerformed = 'revoked-object-access',
pdp?: AbacPdpType,
) => {
return audit(
'abac.action.performed',
{
action: actionPerformed,
reason,
subject,
object,
pdp,
},
{ type: 'system' },
);
},
subjectAttributeChanged: async (diff: IAbacAttributeDefinition[], subject: MinimalUser) => {
return audit(
'abac.subject.attribute.changed',
{
subject,
reason: 'ldap-sync',
diff,
},
{ 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' });
},
};

View File

@@ -0,0 +1,312 @@
import { AbacAccessOperation, AbacObjectType } from '@rocket.chat/core-typings';
import { AbacService } from './index';
const mockSettingsGetValueById = jest.fn();
const mockSubscriptionsFindOneByRoomIdAndUserId = jest.fn();
const mockSubscriptionsSetAbacLastTimeCheckedByUserIdAndRoomId = jest.fn();
const mockUsersFindOne = jest.fn();
const mockUsersFindOneById = jest.fn();
const mockRoomRemoveUserFromRoom = jest.fn().mockResolvedValue(undefined);
jest.mock('@rocket.chat/models', () => ({
Subscriptions: {
findOneByRoomIdAndUserId: (...args: any[]) => mockSubscriptionsFindOneByRoomIdAndUserId(...args),
setAbacLastTimeCheckedByUserIdAndRoomId: (...args: any[]) => mockSubscriptionsSetAbacLastTimeCheckedByUserIdAndRoomId(...args),
},
Users: {
findOne: (...args: any[]) => mockUsersFindOne(...args),
findOneById: (...args: any[]) => mockUsersFindOneById(...args),
},
ServerEvents: {
createAuditServerEvent: jest.fn(),
},
}));
jest.mock('@rocket.chat/core-services', () => ({
ServiceClass: class {
onSettingChanged = jest.fn();
},
Room: {
removeUserFromRoom: (...args: any[]) => mockRoomRemoveUserFromRoom(...args),
},
Settings: {
get: (...args: any[]) => mockSettingsGetValueById(...args),
},
MeteorError: class extends Error {},
isMeteorError: () => false,
}));
describe('AbacService.canAccessObject (unit)', () => {
let service: AbacService;
const baseRoom = {
_id: 'RID',
t: 'p',
abacAttributes: [
{ key: 'dept', values: ['eng', 'sales'] },
{ key: 'region', values: ['emea'] },
],
};
const baseUser = {
_id: 'UID',
username: 'user1',
};
beforeEach(() => {
service = new AbacService();
service.setPdpStrategy('local');
jest.clearAllMocks();
// Default behaviors
mockSettingsGetValueById.mockResolvedValue(300); // 5 minute cache
});
describe('parameter validation & early returns', () => {
it('throws error-abac-unsupported-object-type when objectType is not ROOM', async () => {
await expect(service.canAccessObject(baseRoom as any, baseUser as any, AbacAccessOperation.READ, 'not-room' as any)).rejects.toThrow(
'error-abac-unsupported-object-type',
);
expect(mockSubscriptionsFindOneByRoomIdAndUserId).not.toHaveBeenCalled();
expect(mockUsersFindOne).not.toHaveBeenCalled();
});
it('throws error-abac-unsupported-operation when action is not READ', async () => {
await expect(
service.canAccessObject(baseRoom as any, baseUser as any, AbacAccessOperation.WRITE, AbacObjectType.ROOM),
).rejects.toThrow('error-abac-unsupported-operation');
expect(mockSubscriptionsFindOneByRoomIdAndUserId).not.toHaveBeenCalled();
expect(mockUsersFindOne).not.toHaveBeenCalled();
});
it('returns false when user is missing _id', async () => {
const result = await service.canAccessObject(baseRoom as any, {} as any, AbacAccessOperation.READ, AbacObjectType.ROOM);
expect(result).toBe(false);
expect(mockSubscriptionsFindOneByRoomIdAndUserId).not.toHaveBeenCalled();
expect(mockUsersFindOne).not.toHaveBeenCalled();
});
it('returns false when room has no abacAttributes array', async () => {
const room = { ...baseRoom, abacAttributes: [] };
const result = await service.canAccessObject(room as any, baseUser as any, AbacAccessOperation.READ, AbacObjectType.ROOM);
expect(result).toBe(false);
expect(mockSubscriptionsFindOneByRoomIdAndUserId).not.toHaveBeenCalled();
expect(mockUsersFindOne).not.toHaveBeenCalled();
});
});
describe('subscription presence & compliance evaluation', () => {
it('returns false when user has no subscription to room', async () => {
mockSubscriptionsFindOneByRoomIdAndUserId.mockResolvedValue(null);
const result = await service.canAccessObject(baseRoom as any, baseUser as any, AbacAccessOperation.READ, AbacObjectType.ROOM);
expect(result).toBe(false);
expect(mockSubscriptionsFindOneByRoomIdAndUserId).toHaveBeenCalledWith(baseRoom._id, baseUser._id, {
projection: { abacLastTimeChecked: 1 },
});
expect(mockUsersFindOne).not.toHaveBeenCalled();
});
it('returns false for non-compliant subscription and removes user from room when full user exists', async () => {
mockSubscriptionsFindOneByRoomIdAndUserId.mockResolvedValue({
_id: 'SUB',
abacLastTimeChecked: undefined,
});
mockUsersFindOne.mockResolvedValue(null); // non-compliant
mockUsersFindOneById.mockResolvedValue({ _id: baseUser._id, username: baseUser.username }); // full user for removal
const result = await service.canAccessObject(baseRoom as any, baseUser as any, AbacAccessOperation.READ, AbacObjectType.ROOM);
expect(result).toBe(false);
// Compliance evaluation query assertions
expect(mockUsersFindOne).toHaveBeenCalledTimes(1);
const [query, options] = mockUsersFindOne.mock.calls[0];
expect(query._id).toBe(baseUser._id);
expect(Array.isArray(query.$and)).toBe(true);
expect(query.$and).toHaveLength(baseRoom.abacAttributes.length);
query.$and.forEach((cond: any, idx: number) => {
expect(cond.abacAttributes.$elemMatch.key).toBe(baseRoom.abacAttributes[idx].key);
expect(cond.abacAttributes.$elemMatch.values.$all).toEqual(baseRoom.abacAttributes[idx].values);
});
expect(options).toEqual({ projection: { _id: 1 } });
// Removal path assertions
expect(mockUsersFindOneById).toHaveBeenCalledWith(baseUser._id);
expect(mockRoomRemoveUserFromRoom).toHaveBeenCalledWith(
baseRoom._id,
{ _id: baseUser._id, username: baseUser.username },
{
skipAppPreEvents: true,
customSystemMessage: 'abac-removed-user-from-room',
},
);
expect(mockSubscriptionsSetAbacLastTimeCheckedByUserIdAndRoomId).not.toHaveBeenCalled();
});
it('returns true and updates subscription timestamp when compliant', async () => {
const fakeSub = { _id: 'SUB' };
mockSubscriptionsFindOneByRoomIdAndUserId.mockResolvedValue(fakeSub);
mockUsersFindOne.mockResolvedValue({ _id: baseUser._id });
const result = await service.canAccessObject(baseRoom as any, baseUser as any, AbacAccessOperation.READ, AbacObjectType.ROOM);
expect(result).toBe(true);
expect(mockUsersFindOne).toHaveBeenCalledTimes(1);
expect(mockSubscriptionsSetAbacLastTimeCheckedByUserIdAndRoomId).toHaveBeenCalledWith(baseUser._id, baseRoom._id, expect.any(Date));
});
});
describe('decision cache behavior', () => {
afterAll(() => {
service.decisionCacheTimeout = 300;
});
it('uses cached decision (returns true) when within cache TTL and subscription exists', async () => {
const ttlSeconds = 120;
const within = new Date(Date.now() - (ttlSeconds * 1000 - 500)); // 500ms before expiry
mockSubscriptionsFindOneByRoomIdAndUserId.mockResolvedValue({
_id: 'SUB',
abacLastTimeChecked: within,
});
service.decisionCacheTimeout = ttlSeconds;
const result = await service.canAccessObject(baseRoom as any, baseUser as any, AbacAccessOperation.READ, AbacObjectType.ROOM);
expect(result).toBe(true);
expect(mockUsersFindOne).not.toHaveBeenCalled();
expect(mockSubscriptionsSetAbacLastTimeCheckedByUserIdAndRoomId).not.toHaveBeenCalled();
});
it('re-evaluates when cache expired (timestamp older than TTL)', async () => {
const ttlSeconds = 60;
const expired = new Date(Date.now() - (ttlSeconds * 1000 + 1000));
mockSubscriptionsFindOneByRoomIdAndUserId.mockResolvedValue({
_id: 'SUB',
abacLastTimeChecked: expired,
});
mockUsersFindOne.mockResolvedValue({ _id: baseUser._id });
service.decisionCacheTimeout = ttlSeconds;
const result = await service.canAccessObject(baseRoom as any, baseUser as any, AbacAccessOperation.READ, AbacObjectType.ROOM);
expect(result).toBe(true);
expect(mockUsersFindOne).toHaveBeenCalled();
expect(mockSubscriptionsSetAbacLastTimeCheckedByUserIdAndRoomId).toHaveBeenCalled();
});
it('always evaluates when cache TTL is 0 (disabled)', async () => {
const recent = new Date(); // would be valid if TTL > 0
mockSubscriptionsFindOneByRoomIdAndUserId.mockResolvedValue({
_id: 'SUB',
abacLastTimeChecked: recent,
});
mockUsersFindOne.mockResolvedValue({ _id: baseUser._id });
service.decisionCacheTimeout = 0;
const result = await service.canAccessObject(baseRoom as any, baseUser as any, AbacAccessOperation.READ, AbacObjectType.ROOM);
expect(result).toBe(true);
expect(mockUsersFindOne).toHaveBeenCalledTimes(1);
expect(mockSubscriptionsSetAbacLastTimeCheckedByUserIdAndRoomId).toHaveBeenCalledTimes(1);
});
it('returns false (non-compliant) after cache expiry without updating lastTime', async () => {
const expired = new Date(Date.now() - 15_000);
mockSubscriptionsFindOneByRoomIdAndUserId.mockResolvedValue({
_id: 'SUB',
abacLastTimeChecked: expired,
});
mockUsersFindOne.mockResolvedValue(null); // not compliant
mockUsersFindOneById.mockResolvedValue(null); // user not found path (no removal)
service.decisionCacheTimeout = 10;
const result = await service.canAccessObject(baseRoom as any, baseUser as any, AbacAccessOperation.READ, AbacObjectType.ROOM);
expect(result).toBe(false);
expect(mockUsersFindOne).toHaveBeenCalled();
expect(mockUsersFindOneById).toHaveBeenCalledWith(baseUser._id);
expect(mockRoomRemoveUserFromRoom).not.toHaveBeenCalled();
expect(mockSubscriptionsSetAbacLastTimeCheckedByUserIdAndRoomId).not.toHaveBeenCalled();
});
});
describe('query shape robustness', () => {
it('builds $and conditions proportional to number of room attributes', async () => {
mockSubscriptionsFindOneByRoomIdAndUserId.mockResolvedValue({ _id: 'SUB' });
mockUsersFindOne.mockResolvedValue({ _id: baseUser._id });
await service.canAccessObject(baseRoom as any, baseUser as any, AbacAccessOperation.READ, AbacObjectType.ROOM);
const [query] = mockUsersFindOne.mock.calls[0];
expect(Array.isArray(query.$and)).toBe(true);
expect(query.$and).toHaveLength(baseRoom.abacAttributes.length);
expect(baseRoom.abacAttributes[0]).toEqual({ key: 'dept', values: ['eng', 'sales'] });
});
it('handles single attribute room correctly', async () => {
const singleAttrRoom = {
...baseRoom,
abacAttributes: [{ key: 'dept', values: ['eng'] }],
};
mockSubscriptionsFindOneByRoomIdAndUserId.mockResolvedValue({ _id: 'SUB' });
mockUsersFindOne.mockResolvedValue({ _id: baseUser._id });
await service.canAccessObject(singleAttrRoom as any, baseUser as any, AbacAccessOperation.READ, AbacObjectType.ROOM);
const [query] = mockUsersFindOne.mock.calls[0];
expect(query.$and).toHaveLength(1);
expect(query.$and[0].abacAttributes.$elemMatch.key).toBe('dept');
expect(query.$and[0].abacAttributes.$elemMatch.values.$all).toEqual(['eng']);
});
});
describe('PDP unavailable (fail-closed)', () => {
const usePdp = (over: Record<string, jest.Mock> = {}) => {
const pdp = {
isAvailable: jest.fn().mockResolvedValue(true),
canAccessObject: jest.fn(),
...over,
} as any;
(service as any).pdp = pdp;
return pdp;
};
beforeEach(() => {
mockSubscriptionsFindOneByRoomIdAndUserId.mockResolvedValue({ _id: 'SUB' });
});
it('returns false (does not grant) when the PDP decision call throws', async () => {
const pdp = usePdp({ canAccessObject: jest.fn().mockRejectedValue(new Error('virtru down')) });
const result = await service.canAccessObject(baseRoom as any, baseUser as any, AbacAccessOperation.READ, AbacObjectType.ROOM);
expect(result).toBe(false);
expect(pdp.canAccessObject).toHaveBeenCalled();
expect(mockRoomRemoveUserFromRoom).not.toHaveBeenCalled();
expect(mockSubscriptionsSetAbacLastTimeCheckedByUserIdAndRoomId).not.toHaveBeenCalled();
});
it('returns false without calling the PDP when it reports unavailable', async () => {
const pdp = usePdp({ isAvailable: jest.fn().mockResolvedValue(false) });
const result = await service.canAccessObject(baseRoom as any, baseUser as any, AbacAccessOperation.READ, AbacObjectType.ROOM);
expect(result).toBe(false);
expect(pdp.canAccessObject).not.toHaveBeenCalled();
expect(mockSubscriptionsSetAbacLastTimeCheckedByUserIdAndRoomId).not.toHaveBeenCalled();
});
it('returns false without any DB lookup when no PDP is configured', async () => {
(service as any).pdp = undefined;
const result = await service.canAccessObject(baseRoom as any, baseUser as any, AbacAccessOperation.READ, AbacObjectType.ROOM);
expect(result).toBe(false);
expect(mockSubscriptionsFindOneByRoomIdAndUserId).not.toHaveBeenCalled();
});
});
});

View File

@@ -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');
});
});

View File

@@ -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<IVirtruPDPConfig, 'baseUrl' | 'defaultEntityKey' | 'attributeNamespace' | 'clientId'>;
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<boolean> {
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<string> {
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<string> {
this.tokenCache = null;
return this.getClientToken();
}
async apiCall<T>(endpoint: string, body: unknown): Promise<T> {
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;
}
}

View File

@@ -0,0 +1,50 @@
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('getUserEntityKey never resolves to an unverified email address', () => {
expect(
getUserEntityKey('emailAddress', { _id: 'u', emails: [{ address: 'ceo@company.com', verified: false }], username: 'attacker' }),
).toBeUndefined();
expect(getUserEntityKey('emailAddress', { _id: 'u', emails: [{ address: 'ceo@company.com' }], username: 'attacker' })).toBeUndefined();
expect(
getUserEntityKey('emailAddress', {
_id: 'u',
emails: [
{ address: 'ceo@company.com', verified: false },
{ address: 'real@attacker.com', verified: true },
],
username: 'attacker',
}),
).toBe('real@attacker.com');
});
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'] }]);
});
});

View File

@@ -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<IUser, '_id' | 'emails' | 'username'>): string | undefined {
switch (defaultEntityKey) {
case 'emailAddress':
return user.emails?.find((email) => email.verified)?.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<string, Set<string>>();
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<string>();
bucket.add(value);
grouped.set(key, bucket);
}
const attributes = [...grouped.entries()].map(([key, values]) => ({ key, values: [...values] }));
return { attributes, malformed };
}

View File

@@ -0,0 +1,139 @@
import { MeteorError, isMeteorError } from '@rocket.chat/core-services';
export enum AbacErrorCode {
InvalidAttributeValues = 'error-invalid-attribute-values',
InvalidAttributeKey = 'error-invalid-attribute-key',
AttributeNotFound = 'error-attribute-not-found',
AttributeInUse = 'error-attribute-in-use',
DuplicateAttributeKey = 'error-duplicate-attribute-key',
AttributeDefinitionNotFound = 'error-attribute-definition-not-found',
RoomNotFound = 'error-room-not-found',
CannotConvertDefaultRoomToAbac = 'error-cannot-convert-default-room-to-abac',
AbacUnsupportedObjectType = 'error-abac-unsupported-object-type',
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 {
public readonly code: AbacErrorCode;
public readonly details?: unknown;
constructor(code: AbacErrorCode, details?: unknown) {
super(code);
this.code = code;
this.details = details;
Object.setPrototypeOf(this, new.target.prototype);
}
}
export class AbacInvalidAttributeValuesError extends AbacError {
constructor(details?: unknown) {
super(AbacErrorCode.InvalidAttributeValues, details);
}
}
export class AbacInvalidAttributeKeyError extends AbacError {
constructor(details?: unknown) {
super(AbacErrorCode.InvalidAttributeKey, details);
}
}
export class AbacAttributeNotFoundError extends AbacError {
constructor(details?: unknown) {
super(AbacErrorCode.AttributeNotFound, details);
}
}
export class AbacAttributeInUseError extends AbacError {
constructor(details?: unknown) {
super(AbacErrorCode.AttributeInUse, details);
}
}
export class AbacDuplicateAttributeKeyError extends AbacError {
constructor(details?: unknown) {
super(AbacErrorCode.DuplicateAttributeKey, details);
}
}
export class AbacAttributeDefinitionNotFoundError extends AbacError {
constructor(details?: unknown) {
super(AbacErrorCode.AttributeDefinitionNotFound, details);
}
}
export class AbacRoomNotFoundError extends AbacError {
constructor(details?: unknown) {
super(AbacErrorCode.RoomNotFound, details);
}
}
export class AbacCannotConvertDefaultRoomToAbacError extends AbacError {
constructor(details?: unknown) {
super(AbacErrorCode.CannotConvertDefaultRoomToAbac, details);
}
}
export class AbacUnsupportedObjectTypeError extends AbacError {
constructor(details?: unknown) {
super(AbacErrorCode.AbacUnsupportedObjectType, details);
}
}
export class AbacUnsupportedOperationError extends AbacError {
constructor(details?: unknown) {
super(AbacErrorCode.AbacUnsupportedOperation, details);
}
}
export class OnlyCompliantCanBeAddedToRoomError extends AbacError {
constructor(details?: unknown) {
super(AbacErrorCode.OnlyCompliantCanBeAddedToRoom, details);
}
}
export class PdpUnavailableError extends AbacError {
constructor(details?: unknown) {
super(AbacErrorCode.PdpUnavailable, details);
}
}
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);
this.message = errorCode;
Object.setPrototypeOf(this, new.target.prototype);
}
}
export const getPdpHealthErrorCode = (err: unknown): string => {
if (isMeteorError(err) && err.reason?.startsWith('ABAC_PDP_Health_')) {
return err.reason;
}
return 'ABAC_PDP_Health_Not_OK';
};

View File

@@ -0,0 +1,478 @@
import {
validateAndNormalizeAttributes,
MAX_ABAC_ATTRIBUTE_KEYS,
MAX_ABAC_ATTRIBUTE_VALUES,
diffAttributeSets,
buildRoomNonCompliantConditionsFromSubject,
extractAttribute,
ensureAttributeDefinitionsExist,
buildNonCompliantConditions,
} from './helper';
const attributesFindMock = jest.fn();
jest.mock('@rocket.chat/models', () => {
return {
AbacAttributes: {
find: jest.fn().mockReturnValue({
toArray: () => attributesFindMock(),
}),
},
};
});
describe('validateAndNormalizeAttributes', () => {
it('normalizes keys and merges duplicate values from multiple entries', () => {
const result = validateAndNormalizeAttributes({
' dept ': [' sales ', 'marketing', 'sales', '', 'marketing '],
'role': [' admin ', 'user', 'user '],
'dept': ['engineering'],
});
expect(result).toEqual([
{ key: 'dept', values: ['sales', 'marketing', 'engineering'] },
{ key: 'role', values: ['admin', 'user'] },
]);
});
it('throws when a key has no remaining sanitized values', () => {
expect(() =>
validateAndNormalizeAttributes({
role: [' ', '\n', '\t'],
}),
).toThrow('error-invalid-attribute-values');
});
it('throws when a key exceeds the maximum number of unique values', () => {
const values = Array.from({ length: MAX_ABAC_ATTRIBUTE_VALUES + 1 }, (_, i) => `value-${i}`);
expect(() =>
validateAndNormalizeAttributes({
role: values,
}),
).toThrow('error-invalid-attribute-values');
});
it('throws when attempting to add a new key beyond MAX_ABAC_ATTRIBUTE_KEYS', () => {
const baseEntries = Object.fromEntries(Array.from({ length: MAX_ABAC_ATTRIBUTE_KEYS }, (_, i) => [`key-${i}`, ['value']]));
expect(() =>
validateAndNormalizeAttributes({
...baseEntries,
'extra-key': ['value'],
}),
).toThrow('error-invalid-attribute-values');
});
it('throws when total unique attribute keys exceeds limit (post-aggregation check)', () => {
const attributes = Object.fromEntries(Array.from({ length: MAX_ABAC_ATTRIBUTE_KEYS + 1 }, (_, i) => [`key-${i}`, ['value']]));
expect(() => validateAndNormalizeAttributes(attributes)).toThrow('error-invalid-attribute-values');
});
it('throws when a key exceeds the maximum number of unique values (bucket size limit)', () => {
const values = Array.from({ length: MAX_ABAC_ATTRIBUTE_VALUES + 1 }, (_, i) => `value-${i}`);
expect(() =>
validateAndNormalizeAttributes({
role: values,
}),
).toThrow('error-invalid-attribute-values');
});
it('throws when a key has only invalid/empty values after sanitization', () => {
expect(() =>
validateAndNormalizeAttributes({
role: [' ', '\n', '\t'],
}),
).toThrow('error-invalid-attribute-values');
});
});
describe('ensureAttributeDefinitionsExist', () => {
afterEach(() => {
attributesFindMock.mockReset();
});
it('does nothing when normalized is empty', async () => {
await ensureAttributeDefinitionsExist([]);
});
it('throws when some attribute definitions are missing (size mismatch)', async () => {
const normalized = [
{ key: 'dept', values: ['eng'] },
{ key: 'role', values: ['admin'] },
];
attributesFindMock.mockResolvedValue([{ key: 'dept', values: ['eng', 'sales'] }]);
await expect(ensureAttributeDefinitionsExist(normalized)).rejects.toThrow('error-attribute-definition-not-found');
});
it('throws when a normalized value is not in the allowed definition values', async () => {
const normalized = [{ key: 'dept', values: ['eng', 'support'] }];
attributesFindMock.mockResolvedValue([{ key: 'dept', values: ['eng', 'sales'] }]);
await expect(ensureAttributeDefinitionsExist(normalized)).rejects.toThrow('error-invalid-attribute-values');
});
});
describe('diffAttributeSets', () => {
it('returns false/false when both sets are empty', () => {
const result = diffAttributeSets([], []);
expect(result).toEqual({ added: false, removed: false });
});
it('detects only additions when moving from empty to non-empty', () => {
const current: { key: string; values: string[] }[] = [];
const next: { key: string; values: string[] }[] = [
{ key: 'dept', values: ['eng'] },
{ key: 'role', values: ['admin'] },
];
const result = diffAttributeSets(current, next);
expect(result).toEqual({ added: true, removed: false });
});
it('detects only removals when moving from non-empty to empty', () => {
const current: { key: string; values: string[] }[] = [
{ key: 'dept', values: ['eng'] },
{ key: 'role', values: ['admin'] },
];
const next: { key: string; values: string[] }[] = [];
const result = diffAttributeSets(current, next);
expect(result).toEqual({ added: false, removed: true });
});
it('returns false/false when sets are identical (same keys and values)', () => {
const current: { key: string; values: string[] }[] = [
{ key: 'dept', values: ['eng', 'sales'] },
{ key: 'role', values: ['admin'] },
];
const next: { key: string; values: string[] }[] = [
{ key: 'dept', values: ['eng', 'sales'] },
{ key: 'role', values: ['admin'] },
];
const result = diffAttributeSets(current, next);
expect(result).toEqual({ added: false, removed: false });
});
it('ignores value ordering and still treats sets as identical', () => {
const current: { key: string; values: string[] }[] = [
{ key: 'dept', values: ['eng', 'sales'] },
{ key: 'role', values: ['admin', 'user'] },
];
const next: { key: string; values: string[] }[] = [
{ key: 'role', values: ['user', 'admin'] },
{ key: 'dept', values: ['sales', 'eng'] },
];
const result = diffAttributeSets(current, next);
expect(result).toEqual({ added: false, removed: false });
});
it('detects added values for an existing key', () => {
const current: { key: string; values: string[] }[] = [{ key: 'dept', values: ['eng'] }];
const next: { key: string; values: string[] }[] = [{ key: 'dept', values: ['eng', 'sales'] }];
const result = diffAttributeSets(current, next);
expect(result).toEqual({ added: true, removed: false });
});
it('detects removed values for an existing key', () => {
const current: { key: string; values: string[] }[] = [{ key: 'dept', values: ['eng', 'sales'] }];
const next: { key: string; values: string[] }[] = [{ key: 'dept', values: ['eng'] }];
const result = diffAttributeSets(current, next);
expect(result).toEqual({ added: false, removed: true });
});
it('detects added and removed values on the same key', () => {
const current: { key: string; values: string[] }[] = [{ key: 'dept', values: ['eng', 'sales'] }];
const next: { key: string; values: string[] }[] = [{ key: 'dept', values: ['support'] }];
const result = diffAttributeSets(current, next);
expect(result).toEqual({ added: true, removed: true });
});
it('detects newly added keys as additions', () => {
const current: { key: string; values: string[] }[] = [{ key: 'dept', values: ['eng'] }];
const next: { key: string; values: string[] }[] = [
{ key: 'dept', values: ['eng'] },
{ key: 'role', values: ['admin'] },
];
const result = diffAttributeSets(current, next);
expect(result).toEqual({ added: true, removed: false });
});
it('detects removed keys as removals', () => {
const current: { key: string; values: string[] }[] = [
{ key: 'dept', values: ['eng'] },
{ key: 'role', values: ['admin'] },
];
const next: { key: string; values: string[] }[] = [{ key: 'dept', values: ['eng'] }];
const result = diffAttributeSets(current, next);
expect(result).toEqual({ added: false, removed: true });
});
it('detects both added and removed keys across sets', () => {
const current: { key: string; values: string[] }[] = [
{ key: 'dept', values: ['eng'] },
{ key: 'region', values: ['us'] },
];
const next: { key: string; values: string[] }[] = [
{ key: 'dept', values: ['eng'] },
{ key: 'role', values: ['admin'] },
];
const result = diffAttributeSets(current, next);
expect(result).toEqual({ added: true, removed: true });
});
it('handles mixed changes: new key, removed key, added and removed values', () => {
const current: { key: string; values: string[] }[] = [
{ key: 'dept', values: ['eng', 'sales'] },
{ key: 'region', values: ['us', 'eu'] },
];
const next: { key: string; values: string[] }[] = [
{ key: 'dept', values: ['eng', 'support'] }, // sales removed, support added
{ key: 'role', values: ['admin'] }, // new key
];
const result = diffAttributeSets(current, next);
expect(result).toEqual({ added: true, removed: true });
});
});
describe('buildNonCompliantConditions', () => {
type AttributeDef = { key: string; values: string[] };
it('returns empty array for empty attributes list', () => {
const result = buildNonCompliantConditions([]);
expect(result).toEqual([]);
});
it('maps single attribute to $not $elemMatch query', () => {
const attrs: AttributeDef[] = [{ key: 'dept', values: ['eng', 'sales'] }];
const result = buildNonCompliantConditions(attrs);
expect(result).toEqual([
{
abacAttributes: {
$not: {
$elemMatch: {
key: 'dept',
values: { $all: ['eng', 'sales'] },
},
},
},
},
]);
});
it('maps multiple attributes preserving order', () => {
const attrs: AttributeDef[] = [
{ key: 'dept', values: ['eng'] },
{ key: 'role', values: ['admin', 'user'] },
];
const result = buildNonCompliantConditions(attrs);
expect(result).toEqual([
{
abacAttributes: {
$not: {
$elemMatch: {
key: 'dept',
values: { $all: ['eng'] },
},
},
},
},
{
abacAttributes: {
$not: {
$elemMatch: {
key: 'role',
values: { $all: ['admin', 'user'] },
},
},
},
},
]);
});
});
describe('buildRoomNonCompliantConditionsFromSubject', () => {
it('returns a single condition matching rooms with keys not in the subject set when subject has one key', () => {
const subject = [{ key: 'dept', values: ['eng'] }];
const result = buildRoomNonCompliantConditionsFromSubject(subject);
expect(result).toEqual([
{
abacAttributes: {
$elemMatch: {
key: { $nin: ['dept'] },
},
},
},
{
abacAttributes: {
$elemMatch: {
key: 'dept',
values: { $elemMatch: { $nin: ['eng'] } },
},
},
},
]);
});
it('builds key-$nin condition using all subject keys and per-key $nin for each attribute', () => {
const subject = [
{ key: 'dept', values: ['eng', 'sales'] },
{ key: 'role', values: ['admin'] },
];
const result = buildRoomNonCompliantConditionsFromSubject(subject);
expect(result[0]).toEqual({
abacAttributes: {
$elemMatch: {
key: { $nin: ['dept', 'role'] },
},
},
});
expect(result[1]).toEqual({
abacAttributes: {
$elemMatch: {
key: 'dept',
values: { $elemMatch: { $nin: ['eng', 'sales'] } },
},
},
});
expect(result[2]).toEqual({
abacAttributes: {
$elemMatch: {
key: 'role',
values: { $elemMatch: { $nin: ['admin'] } },
},
},
});
});
it('returns only the key-$nin condition when subject has no values for keys (empty arrays)', () => {
const subject = [
{ key: 'dept', values: [] },
{ key: 'role', values: [] },
];
const result = buildRoomNonCompliantConditionsFromSubject(subject);
expect(result[0]).toEqual({
abacAttributes: {
$elemMatch: {
key: { $nin: ['dept', 'role'] },
},
},
});
expect(result[1]).toEqual({
abacAttributes: {
$elemMatch: {
key: 'dept',
values: { $elemMatch: { $nin: [] } },
},
},
});
expect(result[2]).toEqual({
abacAttributes: {
$elemMatch: {
key: 'role',
values: { $elemMatch: { $nin: [] } },
},
},
});
});
});
describe('extractAttribute', () => {
it('returns undefined when ldapKey or abacKey is missing', () => {
const ldapUser = { memberOf: ['CN=Eng,OU=Groups'] } as any;
expect(extractAttribute(ldapUser, '', 'dept')).toBeUndefined();
expect(extractAttribute(ldapUser, 'memberOf', '')).toBeUndefined();
});
it('returns undefined when ldapUser does not have the provided key', () => {
const ldapUser = { other: ['value'] } as any;
expect(extractAttribute(ldapUser, 'memberOf', 'dept')).toBeUndefined();
});
it('extracts and normalizes a single string value', () => {
const ldapUser = { department: ' Engineering ' } as any;
const result = extractAttribute(ldapUser, 'department', 'dept');
expect(result).toEqual({
key: 'dept',
values: ['Engineering'],
});
});
it('extracts from an array, trimming and deduplicating values', () => {
const ldapUser = {
memberOf: [' Eng ', 'Sales', 'Eng', ' ', '\tSales\t'],
} as any;
const result = extractAttribute(ldapUser, 'memberOf', 'dept');
// Order is preserved by insertion into the Set in implementation: ['Eng', 'Sales']
expect(result).toEqual({
key: 'dept',
values: ['Eng', 'Sales'],
});
});
it('ignores non-string values and empty/whitespace-only strings', () => {
const ldapUser = {
memberOf: [' ', 123, null, ' Eng ', undefined, {}, '\n'],
} as any;
const result = extractAttribute(ldapUser, 'memberOf', 'dept');
expect(result).toEqual({
key: 'dept',
values: ['Eng'],
});
});
it('returns undefined when all values are filtered out', () => {
const ldapUser = {
memberOf: [' ', '\n', '\t'],
} as any;
const result = extractAttribute(ldapUser, 'memberOf', 'dept');
expect(result).toBeUndefined();
});
});

View File

@@ -0,0 +1,320 @@
import type { ILDAPEntry, IAbacAttributeDefinition, IRoom } from '@rocket.chat/core-typings';
import { AbacAttributes, Rooms } from '@rocket.chat/models';
import mem from 'mem';
import {
AbacAttributeDefinitionNotFoundError,
AbacCannotConvertDefaultRoomToAbacError,
AbacInvalidAttributeKeyError,
AbacInvalidAttributeValuesError,
AbacRoomNotFoundError,
} from './errors';
export const MAX_ABAC_ATTRIBUTE_KEYS = 10;
export const MAX_ABAC_ATTRIBUTE_VALUES = 10;
export const extractAttribute = (ldapUser: ILDAPEntry, ldapKey: string, abacKey: string): IAbacAttributeDefinition | undefined => {
if (!ldapKey || !abacKey) {
return;
}
const raw = ldapUser?.[ldapKey];
if (!raw) {
return;
}
const valuesSet = new Set<string>();
const addIfValid = (value: unknown) => {
if (typeof value !== 'string') {
return;
}
const trimmed = value.trim();
if (!trimmed.length) {
return;
}
valuesSet.add(trimmed);
};
if (Array.isArray(raw)) {
for (const v of raw) {
addIfValid(v);
}
} else {
addIfValid(raw);
}
if (!valuesSet.size) {
return;
}
return { key: abacKey, values: Array.from(valuesSet) };
};
export function diffAttributes(a: IAbacAttributeDefinition[] = [], b: IAbacAttributeDefinition[] = []): IAbacAttributeDefinition[] {
if (!a?.length && b?.length) {
return b;
}
if (!b?.length) {
return [];
}
const mapA = new Map(a.map((item) => [item.key, new Set(item.values)]));
const mapB = new Map(b.map((item) => [item.key, new Set(item.values)]));
const diff: IAbacAttributeDefinition[] = [];
// Check keys in A
for (const [key, valuesA] of mapA) {
const valuesB = mapB.get(key);
if (!valuesB) {
// key removed
diff.push({ key, values: [...valuesA] });
continue;
}
const setA = valuesA;
const setB = valuesB;
const changed = [...setA].some((v) => !setB.has(v)) || [...setB].some((v) => !setA.has(v));
if (changed) {
diff.push({ key, values: [...setB] });
}
}
// Check keys added in B
for (const [key, valuesB] of mapB) {
if (!mapA.has(key)) {
diff.push({ key, values: [...valuesB] });
}
}
return diff;
}
export function validateAndNormalizeAttributes(attributes: Record<string, string[]>): IAbacAttributeDefinition[] {
const keyPattern = /^[A-Za-z0-9_-]+$/;
const normalized: IAbacAttributeDefinition[] = [];
const entries = Object.entries(attributes);
const aggregated = new Map<string, Set<string>>();
for (const [rawKey, values] of entries) {
const key = rawKey.trim();
if (!key.length || !keyPattern.test(key)) {
throw new AbacInvalidAttributeKeyError();
}
const bucket = aggregated.get(key) ?? new Set<string>();
if (!aggregated.has(key)) {
if (aggregated.size >= MAX_ABAC_ATTRIBUTE_KEYS) {
throw new AbacInvalidAttributeValuesError();
}
aggregated.set(key, bucket);
}
for (const value of values) {
if (typeof value !== 'string') {
continue;
}
const trimmed = value.trim();
if (!trimmed.length) {
continue;
}
if (bucket.size >= MAX_ABAC_ATTRIBUTE_VALUES && !bucket.has(trimmed)) {
throw new AbacInvalidAttributeValuesError();
}
bucket.add(trimmed);
}
}
if (aggregated.size > MAX_ABAC_ATTRIBUTE_KEYS) {
throw new AbacInvalidAttributeValuesError();
}
for (const [key, valueSet] of aggregated.entries()) {
if (!valueSet.size) {
throw new AbacInvalidAttributeValuesError();
}
normalized.push({ key, values: Array.from(valueSet) });
}
return normalized;
}
const getAttributeDefinitionsFromDb = async (keys: string[]) =>
AbacAttributes.find({ key: { $in: keys } }, { projection: { key: 1, values: 1 } }).toArray();
const getAttributeDefinitionsCached = mem(getAttributeDefinitionsFromDb, {
maxAge: 30_000,
cacheKey: JSON.stringify,
});
export async function ensureAttributeDefinitionsExist(normalized: IAbacAttributeDefinition[]): Promise<void> {
if (!normalized.length) {
return;
}
const uniqueKeys = [...new Set(normalized.map((a) => a.key))];
const attributeDefinitions = await getAttributeDefinitionsCached(uniqueKeys);
const definitionValuesMap = new Map<string, Set<string>>(attributeDefinitions.map((def) => [def.key, new Set(def.values)]));
if (definitionValuesMap.size !== uniqueKeys.length) {
throw new AbacAttributeDefinitionNotFoundError();
}
for (const a of normalized) {
const allowed = definitionValuesMap.get(a.key);
if (!allowed) {
throw new AbacAttributeDefinitionNotFoundError();
}
for (const v of a.values) {
if (!allowed.has(v)) {
throw new AbacInvalidAttributeValuesError();
}
}
}
}
export function buildNonCompliantConditions(newAttributes: IAbacAttributeDefinition[]) {
return newAttributes.map(({ key, values }) => ({
abacAttributes: {
$not: {
$elemMatch: {
key,
values: { $all: values },
},
},
},
}));
}
export function buildCompliantConditions(attributes: IAbacAttributeDefinition[]) {
return attributes.map(({ key, values }) => ({
abacAttributes: {
$elemMatch: {
key,
values: { $all: values },
},
},
}));
}
export function buildRoomNonCompliantConditionsFromSubject(subjectAttributes: IAbacAttributeDefinition[]) {
const map = new Map(subjectAttributes.map((a) => [a.key, new Set(a.values)]));
const userKeys = Array.from(map.keys());
const conditions = [];
conditions.push({
abacAttributes: {
$elemMatch: {
key: { $nin: userKeys },
},
},
});
for (const [key, valuesSet] of map.entries()) {
const valuesArr = Array.from(valuesSet);
conditions.push({
abacAttributes: {
$elemMatch: {
key,
values: { $elemMatch: { $nin: valuesArr } },
},
},
});
}
return conditions;
}
export async function getAbacRoom(rid: string): Promise<IRoom> {
const room = await Rooms.findOneByIdAndType(rid, 'p');
if (!room) {
throw new AbacRoomNotFoundError();
}
if (room.default || room.teamDefault) {
throw new AbacCannotConvertDefaultRoomToAbacError();
}
return room;
}
export function diffAttributeSets(
current: IAbacAttributeDefinition[] = [],
next: IAbacAttributeDefinition[] = [],
): { added: boolean; removed: boolean } {
const currentMap = new Map<string, Set<string>>(current.map((attr) => [attr.key, new Set(attr.values)]));
const nextMap = new Map<string, Set<string>>(next.map((attr) => [attr.key, new Set(attr.values)]));
let added = false;
let removed = false;
// Check removals and per-key value changes
for (const [key, currentValues] of currentMap) {
const nextValues = nextMap.get(key);
if (!nextValues) {
// Key was completely removed
removed = true;
} else {
// Check removed values
for (const v of currentValues) {
if (!nextValues.has(v)) {
removed = true;
break;
}
}
}
if (removed) {
break;
}
}
// Check additions (new keys or new values on existing keys)
if (!removed) {
for (const [key, nextValues] of nextMap) {
const currentValues = currentMap.get(key);
if (!currentValues) {
// New key added
added = true;
break;
}
for (const v of nextValues) {
if (!currentValues.has(v)) {
added = true;
break;
}
}
if (added) {
break;
}
}
} else {
// Even if we've already seen removals, we might still want to know if additions happened too
for (const [key, nextValues] of nextMap) {
const currentValues = currentMap.get(key);
if (!currentValues) {
added = true;
break;
}
for (const v of nextValues) {
if (!currentValues.has(v)) {
added = true;
break;
}
}
if (added) {
break;
}
}
}
return { added, removed };
}
export const stripTrailingSlashes = (value: string): string => value.replace(/\/+$/, '');

View File

@@ -0,0 +1,989 @@
import { api, Authorization, License, Room, ServiceClass, Settings } from '@rocket.chat/core-services';
import type { AbacActor, IAbacService } from '@rocket.chat/core-services';
import { AbacAccessOperation, AbacObjectType, isAbacPdpType, isAbacAttributeStoreType } from '@rocket.chat/core-typings';
import type {
IAbacAttribute,
IAbacAttributeDefinition,
IRoom,
IRoomAbacRedaction,
AtLeast,
IUser,
ILDAPEntry,
AbacAuditReason,
AbacAttributeStoreType,
AbacPdpType,
AbacUserIdentifiers,
} from '@rocket.chat/core-typings';
import { Rooms, AbacAttributes, Users, Subscriptions } from '@rocket.chat/models';
import { escapeRegExp, isTruthy } from '@rocket.chat/tools';
import type { Document, UpdateFilter } from 'mongodb';
import pLimit from 'p-limit';
import { Audit } from './audit';
import { VirtruClient } from './clients/virtru/VirtruClient';
import {
AbacAttributeInUseError,
AbacAttributeNotFoundError,
AbacDuplicateAttributeKeyError,
AbacInvalidAttributeValuesError,
AbacUnsupportedObjectTypeError,
AbacUnsupportedOperationError,
PdpUnavailableError,
PdpHealthCheckError,
} from './errors';
import {
getAbacRoom,
diffAttributes,
extractAttribute,
diffAttributeSets,
validateAndNormalizeAttributes,
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);
export class AbacService extends ServiceClass implements IAbacService {
protected name = 'abac';
private pdp: IPolicyDecisionPoint | null = null;
private virtruPdpConfig: VirtruPDPConfig = {
baseUrl: '',
clientId: '',
clientSecret: '',
oidcEndpoint: '',
defaultEntityKey: 'emailAddress',
attributeNamespace: 'example.com',
};
private virtruClient = new VirtruClient(this.virtruPdpConfig);
private abacEnabled?: boolean;
private pdpTypeSetting?: AbacPdpType;
private attributeStoreSetting?: AbacAttributeStoreType;
private readonly attributeStores: Record<AbacAttributeStoreType, AttributeStoreDescriptor> = {
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() {
super();
this.onSettingChanged('ABAC_PDP_Type', async ({ setting }): Promise<void> => {
const { value } = setting;
if (!isAbacPdpType(value)) {
return;
}
if (value === 'virtru') {
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<void> => {
this.abacEnabled = setting.value as boolean;
});
this.onSettingChanged('ABAC_Attribute_Store', async ({ setting }): Promise<void> => {
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<void> => {
const { value } = setting;
if (typeof value !== 'number') {
return;
}
this.decisionCacheTimeout = value;
});
this.onSettingChanged('ABAC_Virtru_Base_URL', async ({ setting }): Promise<void> => {
this.virtruPdpConfig.baseUrl = stripTrailingSlashes(setting.value as string);
this.syncVirtruPdpConfig();
});
this.onSettingChanged('ABAC_Virtru_Client_ID', async ({ setting }): Promise<void> => {
this.virtruPdpConfig.clientId = setting.value as string;
this.syncVirtruPdpConfig();
});
this.onSettingChanged('ABAC_Virtru_Client_Secret', async ({ setting }): Promise<void> => {
this.virtruPdpConfig.clientSecret = setting.value as string;
this.syncVirtruPdpConfig();
});
this.onSettingChanged('ABAC_Virtru_OIDC_Endpoint', async ({ setting }): Promise<void> => {
this.virtruPdpConfig.oidcEndpoint = stripTrailingSlashes(setting.value as string);
this.syncVirtruPdpConfig();
});
this.onSettingChanged('ABAC_Virtru_Default_Entity_Key', async ({ setting }): Promise<void> => {
this.virtruPdpConfig.defaultEntityKey = setting.value as VirtruPDPConfig['defaultEntityKey'];
this.syncVirtruPdpConfig();
});
this.onSettingChanged('ABAC_Virtru_Attribute_Namespace', async ({ setting }): Promise<void> => {
this.virtruPdpConfig.attributeNamespace = setting.value as string;
this.syncVirtruPdpConfig();
});
}
private async loadVirtruPdpConfig(): Promise<void> {
const [baseUrl, clientId, clientSecret, oidcEndpoint, defaultEntityKey, attributeNamespace] = await Promise.all([
Settings.get<string>('ABAC_Virtru_Base_URL'),
Settings.get<string>('ABAC_Virtru_Client_ID'),
Settings.get<string>('ABAC_Virtru_Client_Secret'),
Settings.get<string>('ABAC_Virtru_OIDC_Endpoint'),
Settings.get<string>('ABAC_Virtru_Default_Entity_Key'),
Settings.get<string>('ABAC_Virtru_Attribute_Namespace'),
]);
this.virtruPdpConfig = {
baseUrl: stripTrailingSlashes(baseUrl || ''),
clientId: clientId || '',
clientSecret: clientSecret || '',
oidcEndpoint: stripTrailingSlashes(oidcEndpoint || ''),
defaultEntityKey: (defaultEntityKey as VirtruPDPConfig['defaultEntityKey']) || 'emailAddress',
attributeNamespace: attributeNamespace || 'example.com',
};
}
private syncVirtruPdpConfig(): void {
this.virtruClient.updateConfig({ ...this.virtruPdpConfig });
this.lastSelectedStore = undefined;
}
private async computeEffectiveStoreType(): Promise<AbacAttributeStoreType> {
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<void> {
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<IAttributeStore> {
const effective = await this.computeEffectiveStoreType();
const { store } = this.attributeStores[effective];
if (store !== this.lastSelectedStore) {
this.lastSelectedStore = store;
store.onStoreSelected?.();
}
return store;
}
async isExternalAttributeStore(): Promise<boolean> {
return (await this.resolveAttributeStore()) !== this.attributeStores.local.store;
}
private async onAttributeStoreTransition(from: AbacAttributeStoreType, to: AbacAttributeStoreType): Promise<void> {
if (!(await License.hasModule('abac'))) {
return;
}
const { modifiedCount } = await Rooms.unsetAllAbacAttributes();
if (modifiedCount > 0) {
void Audit.attributeStoreSwitched(from, to, modifiedCount);
}
}
setPdpStrategy(strategy: AbacPdpType): void {
const previousPdp = this.pdp ? this.pdp.constructor.name : 'none';
switch (strategy) {
case 'virtru':
this.virtruClient.updateConfig({ ...this.virtruPdpConfig });
this.pdp = new VirtruPDP(this.virtruClient);
this.pdpType = 'virtru';
break;
case 'local':
default:
this.pdp = new LocalPDP();
this.pdpType = 'local';
break;
}
logger.debug({
msg: 'PDP strategy changed',
from: previousPdp,
to: this.pdp.constructor.name,
requestedStrategy: strategy,
});
}
override async started(): Promise<void> {
this.decisionCacheTimeout = await Settings.get<number>('Abac_Cache_Decision_Time_Seconds');
const [abacEnabled, pdpType, attributeStore] = await Promise.all([
Settings.get<boolean>('ABAC_Enabled'),
Settings.get<string>('ABAC_PDP_Type'),
Settings.get<string>('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;
}
await this.loadVirtruPdpConfig();
this.setPdpStrategy('virtru');
}
async addSubjectAttributes(user: IUser, ldapUser: ILDAPEntry, map: Record<string, string>): Promise<void> {
if (!user?._id) {
return;
}
const entries = Object.entries(map || {});
const mergedMap = new Map<string, Set<string>>();
for (const [ldapKey, abacKey] of entries) {
const attr = extractAttribute(ldapUser, ldapKey, abacKey);
if (!attr) {
continue;
}
const existing = mergedMap.get(attr.key);
if (!existing) {
mergedMap.set(attr.key, new Set(attr.values));
continue;
}
for (const v of attr.values) {
existing.add(v);
}
}
const finalAttributes = Array.from(mergedMap.entries()).map<IAbacAttributeDefinition>(([key, valuesSet]) => ({
key,
values: Array.from(valuesSet),
}));
if (!finalAttributes.length) {
if (Array.isArray(user.abacAttributes) && user.abacAttributes.length) {
const finalUser = await Users.unsetAbacAttributesById(user._id);
await this.onSubjectAttributesChanged(finalUser!, []);
void Audit.subjectAttributeChanged([], { _id: user._id, username: user.username });
}
return;
}
const finalUser = await Users.setAbacAttributesById(user._id, finalAttributes);
if (diffAttributeSets(user?.abacAttributes || [], finalAttributes).removed) {
await this.onSubjectAttributesChanged(finalUser!, finalAttributes);
}
const diff = diffAttributes(user?.abacAttributes, finalAttributes);
if (diff.length) {
void Audit.subjectAttributeChanged(diff, { _id: user._id, username: user.username });
}
}
async addAbacAttribute(attribute: IAbacAttributeDefinition, actor: AbacActor): Promise<void> {
if (!attribute.values.length) {
throw new AbacInvalidAttributeValuesError();
}
try {
await AbacAttributes.insertOne(attribute);
void Audit.attributeCreated(attribute, actor);
} catch (e) {
if (e instanceof Error && e.message.includes('E11000')) {
throw new AbacDuplicateAttributeKeyError();
}
throw e;
}
}
async listAbacAttributes(
filters?: { key?: string; values?: string; offset?: number; count?: number },
actor?: AbacActor,
): Promise<{
attributes: Pick<IAbacAttribute, '_id' | 'key' | 'values'>[];
offset: number;
count: number;
total: number;
}> {
return (await this.resolveAttributeStore()).list(actor, filters);
}
async listAbacRooms(
filters?: {
offset?: number;
count?: number;
filter?: string;
filterType?: 'all' | 'roomName' | 'attribute' | 'value';
},
actor?: AbacActor,
): Promise<{
rooms: Array<IRoom & IRoomAbacRedaction>;
offset: number;
count: number;
total: number;
}> {
const offset = filters?.offset ?? 0;
const limit = filters?.count ?? 25;
const baseQuery: Document = {
t: 'p',
abacAttributes: { $exists: true, $ne: [] },
};
const { filter, filterType } = filters || {};
if (filter?.trim().length) {
const regex = new RegExp(escapeRegExp(filter.trim()), 'i');
let condition: Document;
switch (filterType) {
case 'roomName':
condition = { $or: [{ name: regex }, { fname: regex }] };
break;
case 'attribute':
condition = { 'abacAttributes.key': regex };
break;
case 'value':
condition = { 'abacAttributes.values': regex };
break;
case 'all':
default:
condition = {
$or: [{ name: regex }, { fname: regex }, { 'abacAttributes.key': regex }, { 'abacAttributes.values': regex }],
};
break;
}
Object.assign(baseQuery, condition);
}
const { cursor, totalCount } = Rooms.findPaginated(baseQuery, {
skip: offset,
limit,
sort: { name: 1 },
});
const rooms = await cursor.toArray();
const scoped = actor ? await (await this.resolveAttributeStore()).scopeRoomsPage(rooms, actor) : rooms;
return {
rooms: scoped,
offset,
count: scoped.length,
total: await totalCount,
};
}
async scopeRoomsForAdmin<T extends Pick<IRoom, '_id' | 'abacAttributes'>>(
rooms: T[],
actor: AbacActor,
): Promise<Array<T & IRoomAbacRedaction>> {
return (await this.resolveAttributeStore()).scopeRoomsPage(rooms, actor);
}
async updateAbacAttributeById(_id: string, update: { key?: string; values?: string[] }, actor: AbacActor): Promise<void> {
if (!update.key && !update.values) {
return;
}
const existing = await AbacAttributes.findOneById(_id, { projection: { key: 1, values: 1 } });
if (!existing) {
throw new AbacAttributeNotFoundError();
}
if (update.values && !update.values.length) {
throw new AbacInvalidAttributeValuesError();
}
const newKey = update.key ?? existing.key;
const newValues = update.values ?? existing.values;
const removedValues = existing.values.filter((v) => !newValues.includes(v));
const keyChanged = newKey !== existing.key;
const valuesToCheck = keyChanged ? existing.values : removedValues;
if (keyChanged || valuesToCheck.length) {
const inUse = await Rooms.isAbacAttributeInUse(existing.key, valuesToCheck.length ? valuesToCheck : existing.values);
if (inUse) {
throw new AbacAttributeInUseError();
}
}
const modifier: UpdateFilter<IAbacAttribute> = {};
if (update.key) {
modifier.key = update.key;
}
if (update.values) {
modifier.values = update.values;
}
if (!Object.keys(modifier).length) {
return;
}
try {
await AbacAttributes.updateOne({ _id }, { $set: modifier });
void Audit.attributeUpdated(existing, modifier as IAbacAttributeDefinition, actor);
} catch (e) {
if (e instanceof Error && e.message.includes('E11000')) {
throw new AbacDuplicateAttributeKeyError();
}
throw e;
}
}
async deleteAbacAttributeById(_id: string, actor: AbacActor): Promise<void> {
const existing = await AbacAttributes.findOneById(_id, { projection: { key: 1, values: 1 } });
if (!existing) {
throw new AbacAttributeNotFoundError();
}
const inUse = await Rooms.isAbacAttributeInUse(existing.key, existing.values);
if (inUse) {
throw new AbacAttributeInUseError();
}
await AbacAttributes.removeById(_id);
void Audit.attributeDeleted(existing, actor);
}
async getAbacAttributeById(_id: string, _actor: AbacActor | undefined): Promise<{ key: string; values: string[] }> {
const attribute = await AbacAttributes.findOneById(_id, { projection: { key: 1, values: 1 } });
if (!attribute) {
throw new AbacAttributeNotFoundError();
}
return {
key: attribute.key,
values: attribute.values || [],
};
}
async isAbacAttributeInUseByKey(key: string): Promise<boolean> {
const attribute = await AbacAttributes.findOneByKey(key, { projection: { values: 1 } });
if (!attribute) {
return false;
}
return Rooms.isAbacAttributeInUse(key, attribute.values || []);
}
private broadcastRoomUpdate(room: IRoom): void {
void api.broadcast('watch.rooms', { clientAction: 'updated', room });
}
private async enforceCanModifyRoom(store: IAttributeStore, room: Pick<IRoom, '_id' | 'abacAttributes'>, actor: AbacActor): Promise<void> {
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<void> {
if (await Authorization.hasPermission(actor._id, 'bypass-abac-store-validation')) {
return;
}
await store.validateAssignable(attrs, actor);
}
async setRoomAbacAttributes(rid: string, attributes: Record<string, string[]>, actor: AbacActor): Promise<void> {
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);
void Audit.objectAttributesRemoved({ _id: room._id, name: room.name }, room.abacAttributes, actor);
this.broadcastRoomUpdate({ ...room, abacAttributes: undefined });
return;
}
await this.enforceCanModifyRoom(store, room, actor);
const normalized = validateAndNormalizeAttributes(attributes);
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);
if (updated) {
this.broadcastRoomUpdate(updated);
}
const previous: IAbacAttributeDefinition[] = room.abacAttributes || [];
if (diffAttributeSets(previous, normalized).added) {
await this.onRoomAttributesChanged(room, updated?.abacAttributes ?? normalized);
}
}
async updateRoomAbacAttributeValues(rid: string, key: string, values: string[], actor: AbacActor): Promise<void> {
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 existingIndex = previous.findIndex((a) => a.key === key);
const isNewKey = existingIndex === -1;
if (isNewKey && previous.length >= MAX_ABAC_ATTRIBUTE_KEYS) {
throw new AbacInvalidAttributeValuesError();
}
await this.enforceStoreValidation(store, [{ key, values }], actor);
if (isNewKey) {
await Rooms.updateSingleAbacAttributeValuesById(rid, key, values);
void Audit.objectAttributeChanged(
{ _id: room._id, name: room.name },
room.abacAttributes || [],
[{ key, values }],
'key-added',
actor,
);
const next = [...previous, { key, values }];
this.broadcastRoomUpdate({ ...room, abacAttributes: next });
await this.onRoomAttributesChanged(room, next);
return;
}
const prevValues = previous[existingIndex].values;
if (prevValues.length === values.length && prevValues.every((v, i) => v === values[i])) {
return;
}
const updated = await Rooms.updateAbacAttributeValuesArrayFilteredById(rid, key, values);
void Audit.objectAttributeChanged(
{ _id: room._id, name: room.name },
room.abacAttributes || [],
[{ key, values }],
'key-updated',
actor,
);
if (updated) {
this.broadcastRoomUpdate(updated);
}
if (diffAttributeSets([previous[existingIndex]], [{ key, values }]).added) {
const next = previous.map((a, i) => (i === existingIndex ? { key, values } : a));
await this.onRoomAttributesChanged(room, next);
}
}
async removeRoomAbacAttribute(rid: string, key: string, actor: AbacActor): Promise<void> {
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);
if (!exists) {
return;
}
// if is the last attribute, just remove all
if (previous.length === 1) {
await Rooms.unsetAbacAttributesById(rid);
void Audit.objectAttributesRemoved({ _id: room._id }, previous, actor);
this.broadcastRoomUpdate({ ...room, abacAttributes: undefined });
return;
}
await Rooms.removeAbacAttributeByRoomIdAndKey(rid, key);
const next = previous.filter((a) => a.key !== key);
void Audit.objectAttributeRemoved({ _id: room._id, name: room.name }, previous, next, 'key-removed', actor);
this.broadcastRoomUpdate({ ...room, abacAttributes: next });
}
async addRoomAbacAttributeByKey(rid: string, key: string, values: string[], actor: AbacActor): Promise<void> {
await this.ensurePdpAvailable();
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)) {
throw new AbacDuplicateAttributeKeyError();
}
if (previous.length >= MAX_ABAC_ATTRIBUTE_KEYS) {
throw new AbacInvalidAttributeValuesError();
}
const updated = await Rooms.insertAbacAttributeIfNotExistsById(rid, key, values);
const next = updated?.abacAttributes || [...previous, { key, values }];
void Audit.objectAttributeChanged({ _id: room._id, name: room.name }, previous, next, 'key-added', actor);
this.broadcastRoomUpdate({ ...room, abacAttributes: next });
await this.onRoomAttributesChanged(room, next);
}
async replaceRoomAbacAttributeByKey(rid: string, key: string, values: string[], actor: AbacActor): Promise<void> {
await this.ensurePdpAvailable();
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);
if (exists) {
const updated = await Rooms.updateAbacAttributeValuesArrayFilteredById(rid, key, values);
void Audit.objectAttributeChanged(
{ _id: room._id, name: room.name },
room.abacAttributes || [],
updated?.abacAttributes || [],
'key-updated',
actor,
);
if (updated) {
this.broadcastRoomUpdate(updated);
}
if (diffAttributeSets([exists], [{ key, values }]).added) {
await this.onRoomAttributesChanged(room, updated?.abacAttributes || []);
}
return;
}
if (room?.abacAttributes?.length === MAX_ABAC_ATTRIBUTE_KEYS) {
throw new AbacInvalidAttributeValuesError();
}
const updated = await Rooms.insertAbacAttributeIfNotExistsById(rid, key, values);
const nextAttributes = updated?.abacAttributes || [...(room.abacAttributes || []), { key, values }];
void Audit.objectAttributeChanged({ _id: room._id, name: room.name }, room.abacAttributes || [], nextAttributes, 'key-added', actor);
this.broadcastRoomUpdate({ ...room, abacAttributes: nextAttributes });
await this.onRoomAttributesChanged(room, updated?.abacAttributes || []);
}
private shouldUseCache(userSub: { abacLastTimeChecked?: Date }): boolean {
return (
this.decisionCacheTimeout > 0 &&
!!userSub.abacLastTimeChecked &&
Date.now() - userSub.abacLastTimeChecked.getTime() < this.decisionCacheTimeout * 1000
);
}
async canAccessObject(
room: Pick<IRoom, '_id' | 't' | 'teamId' | 'prid' | 'abacAttributes'>,
user: Pick<IUser, '_id'>,
action: AbacAccessOperation,
objectType: AbacObjectType,
) {
// We may need this flex for phase 2, but for now only ROOM/READ is supported
if (objectType !== AbacObjectType.ROOM) {
throw new AbacUnsupportedObjectTypeError();
}
if (action !== AbacAccessOperation.READ) {
throw new AbacUnsupportedOperationError();
}
if (!user?._id || !room?.abacAttributes?.length) {
return false;
}
if (!this.pdp) {
return false;
}
const userSub = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { projection: { abacLastTimeChecked: 1 } });
if (!userSub) {
return false;
}
if (this.shouldUseCache(userSub)) {
logger.debug({ msg: 'Using cached ABAC decision', userId: user._id, roomId: room._id });
return true;
}
if (!(await this.pdp.isAvailable())) {
return false;
}
let decision: { granted: boolean; userToRemove?: IUser };
try {
decision = await this.pdp.canAccessObject(room, user);
} catch (err) {
logger.error({ msg: 'PDP canAccessObject failed', userId: user._id, roomId: room._id, err });
return false;
}
if (decision.userToRemove) {
await this.removeUserFromRoom(room, decision.userToRemove, 'realtime-policy-eval');
}
if (decision.granted) {
await Subscriptions.setAbacLastTimeCheckedByUserIdAndRoomId(user._id, room._id, new Date());
}
return decision.granted;
}
async checkUsernamesMatchAttributes(usernames: string[], attributes: IAbacAttributeDefinition[], object: IRoom): Promise<void> {
if (!usernames.length || !attributes.length || !this.pdp) {
return;
}
await this.ensurePdpAvailable();
await this.pdp.checkUsernamesMatchAttributes(usernames, attributes, object);
usernames.forEach((username) => {
void Audit.actionPerformed({ username }, { _id: object._id, name: object.name }, 'system', 'granted-object-access');
});
}
private pdpType: AbacPdpType = 'local';
private async ensurePdpAvailable(): Promise<void> {
if (!(await this.pdp?.isAvailable())) {
throw new PdpUnavailableError();
}
}
private async removeUserFromRoom(room: AtLeast<IRoom, '_id'>, user: IUser, reason: AbacAuditReason): Promise<void> {
return Room.removeUserFromRoom(room._id, user, {
skipAppPreEvents: true,
customSystemMessage: 'abac-removed-user-from-room' as const,
})
.then(
() =>
void Audit.actionPerformed(
{ _id: user._id, username: user.username },
{ _id: room._id, name: room.name },
reason,
'revoked-object-access',
this.pdpType,
),
)
.catch((err) => {
logger.error({
msg: 'Failed to remove user from ABAC room',
rid: room._id,
err,
reason,
});
});
}
protected async onRoomAttributesChanged(
room: AtLeast<IRoom, '_id' | 't' | 'teamMain' | 'abacAttributes'>,
newAttributes: IAbacAttributeDefinition[],
): Promise<void> {
const rid = room._id;
if (!newAttributes?.length) {
// When a room has no ABAC attributes, it becomes a normal private group and no user removal is necessary
logger.warn({
msg: 'Room ABAC attributes removed. Room is not abac managed anymore',
rid,
});
return;
}
if (!this.pdp) {
return;
}
try {
const nonCompliantUsers = await this.pdp.onRoomAttributesChanged(room, newAttributes);
if (!nonCompliantUsers.length) {
return;
}
await Promise.all(nonCompliantUsers.map((user) => limit(() => this.removeUserFromRoom(room, user, 'room-attributes-change'))));
} catch (err) {
logger.error({
msg: 'Failed to re-evaluate room subscriptions after ABAC attributes changed',
rid,
err,
});
}
}
protected async onSubjectAttributesChanged(user: IUser, _next: IAbacAttributeDefinition[]): Promise<void> {
if (!user?._id || !Array.isArray(user.__rooms) || !user.__rooms.length || !this.pdp) {
return;
}
if (!(await this.pdp.isAvailable())) {
return;
}
try {
const nonCompliantRooms = await this.pdp.onSubjectAttributesChanged(user, _next);
if (!nonCompliantRooms.length) {
return;
}
await Promise.all(nonCompliantRooms.map((room) => limit(() => this.removeUserFromRoom(room, user, 'ldap-sync'))));
} catch (err) {
logger.error({
msg: 'Failed to query and remove user from non-compliant ABAC rooms',
err,
});
}
}
async getPDPHealth(): Promise<void> {
if (!this.pdp) {
logger.warn({ msg: 'ABAC PDP health check: no PDP configured' });
throw new PdpHealthCheckError('ABAC_PDP_Health_No_PDP');
}
await this.pdp.getHealthStatus();
}
async evaluateRoomMembership(): Promise<void> {
if (!this.pdp || !(await this.pdp.isAvailable())) {
return;
}
const abacRooms = await Rooms.findAllPrivateRoomsWithAbacAttributes({
projection: { _id: 1, t: 1, teamMain: 1, abacAttributes: 1 },
}).toArray();
if (!abacRooms.length) {
return;
}
const abacRoomById = Object.fromEntries(abacRooms.map((room) => [room._id, room]));
const abacRoomIds = abacRooms.map((room) => room._id);
const users = Users.findActiveByRoomIds(abacRoomIds, {
projection: { _id: 1, emails: 1, username: 1, __rooms: 1 },
});
const entries = (
await users
.map((user) => {
const rooms = (user.__rooms ?? []).map((rid) => abacRoomById[rid]).filter(Boolean);
return rooms.length ? { user, rooms } : null;
})
.toArray()
).filter(isTruthy);
if (!entries.length) {
return;
}
try {
const nonCompliant = await this.pdp.evaluateUserRooms(entries);
// TODO: this should be in a persistent queue
await Promise.all(nonCompliant.map(({ user, room }) => limit(() => this.removeUserFromRoom(room, user as IUser, 'virtru-pdp-sync'))));
} catch (err) {
logger.error({ msg: 'Failed to evaluate room membership', err });
}
}
async reevaluateUsers(identifiers: AbacUserIdentifiers): Promise<void> {
if (!this.pdp || !(await this.pdp.isAvailable())) {
return;
}
const users = await Users.findUsersByIdentifiers(identifiers, {
projection: { _id: 1, emails: 1, username: 1, __rooms: 1 },
}).toArray();
if (!users.length) {
return;
}
try {
const nonCompliant = await this.pdp.reevaluateUsers(users);
if (Array.isArray(nonCompliant) && nonCompliant.length) {
await Promise.all(nonCompliant.map(({ user, room }) => limit(() => this.removeUserFromRoom(room, user as IUser, 'api'))));
}
} catch (err) {
logger.error({ msg: 'Failed to reevaluate users', err });
throw err;
}
}
}
export { LocalPDP, VirtruPDP } from './pdp';
export type { IPolicyDecisionPoint, VirtruPDPConfig } from './pdp';
export { PdpHealthCheckError, getPdpHealthErrorCode, AbacAttributeStoreExternalError } from './errors';
export default AbacService;

View File

@@ -0,0 +1,3 @@
import { Logger } from '@rocket.chat/logger';
export const logger = new Logger('AbacService');

View File

@@ -0,0 +1,106 @@
import { LDAPEnterprise } from '@rocket.chat/core-services';
import type { IAbacAttributeDefinition, IRoom, AtLeast, IUser } from '@rocket.chat/core-typings';
import { Rooms, Users } from '@rocket.chat/models';
import { OnlyCompliantCanBeAddedToRoomError } from '../errors';
import { buildCompliantConditions, buildNonCompliantConditions, buildRoomNonCompliantConditionsFromSubject } from '../helper';
import type { IPolicyDecisionPoint, ReevaluationUser } from './types';
export class LocalPDP implements IPolicyDecisionPoint {
async isAvailable(): Promise<boolean> {
return true;
}
async getHealthStatus(): Promise<void> {
// Local PDP is always available, nothing to check
}
async canAccessObject(
room: AtLeast<IRoom, '_id' | 'abacAttributes'>,
user: AtLeast<IUser, '_id'>,
): Promise<{ granted: boolean; userToRemove?: IUser }> {
const isUserCompliant = await Users.findOne(
{
_id: user._id,
$and: buildCompliantConditions(room.abacAttributes ?? []),
},
{ projection: { _id: 1 } },
);
if (!isUserCompliant) {
const fullUser = await Users.findOneById(user._id);
if (!fullUser) {
return { granted: false };
}
return { granted: false, userToRemove: fullUser };
}
return { granted: true };
}
async onRoomAttributesChanged(
room: AtLeast<IRoom, '_id' | 't' | 'teamMain' | 'abacAttributes'>,
newAttributes: IAbacAttributeDefinition[],
): Promise<IUser[]> {
const query = {
__rooms: room._id,
$or: buildNonCompliantConditions(newAttributes),
};
return Users.find(query, { projection: { __rooms: 0 } }).toArray();
}
async onSubjectAttributesChanged(user: IUser, _next: IAbacAttributeDefinition[]): Promise<Pick<IRoom, '_id'>[]> {
const roomIds = user.__rooms;
// No attributes: no rooms :(
if (!_next.length) {
return Rooms.find(
{
_id: { $in: roomIds },
abacAttributes: { $exists: true, $ne: [] },
},
{ projection: { _id: 1 } },
).toArray();
}
const query = {
_id: { $in: roomIds },
$or: buildRoomNonCompliantConditionsFromSubject(_next),
};
return Rooms.find(query, { projection: { _id: 1 } }).toArray();
}
async evaluateUserRooms(
_entries: Array<{
user: Pick<IUser, '_id' | 'emails' | 'username'>;
rooms: AtLeast<IRoom, '_id' | 'abacAttributes'>[];
}>,
): Promise<Array<{ user: Pick<IUser, '_id' | 'emails' | 'username'>; room: IRoom }>> {
throw new Error('evaluateUserRooms is not implemented for LocalPDP');
}
async reevaluateUsers(users: ReevaluationUser[]): Promise<void> {
await LDAPEnterprise.syncUsersAbacAttributesByIds(users.map((user) => user._id));
}
async checkUsernamesMatchAttributes(usernames: string[], attributes: IAbacAttributeDefinition[], _object: IRoom): Promise<void> {
const nonCompliantUsersFromList = await Users.find(
{
username: { $in: usernames },
$or: buildNonCompliantConditions(attributes),
},
{ projection: { username: 1 } },
)
.map((u) => u.username as string)
.toArray();
const nonCompliantSet = new Set<string>(nonCompliantUsersFromList);
if (nonCompliantSet.size) {
throw new OnlyCompliantCanBeAddedToRoomError();
}
}
}

View File

@@ -0,0 +1,668 @@
import { OnlyCompliantCanBeAddedToRoomError, PdpHealthCheckError } from '../errors';
import { getDeniedSubjects, VirtruPDP } from './VirtruPDP';
import type { Decision } from './types';
const serverFetchMock = jest.fn();
jest.mock('@rocket.chat/server-fetch', () => ({ serverFetch: (...a: unknown[]) => serverFetchMock(...a) }));
const usersFindOneById = jest.fn();
const usersFindByUsernames = jest.fn();
const usersFindActiveByRoomIds = jest.fn();
const roomsFindPrivateRoomsByIdsWithAbacAttributes = jest.fn();
jest.mock('@rocket.chat/models', () => ({
Users: {
findOneById: (...a: unknown[]) => usersFindOneById(...a),
findByUsernames: (...a: unknown[]) => usersFindByUsernames(...a),
findActiveByRoomIds: (...a: unknown[]) => usersFindActiveByRoomIds(...a),
},
Rooms: {
findPrivateRoomsByIdsWithAbacAttributes: (...a: unknown[]) => roomsFindPrivateRoomsByIdsWithAbacAttributes(...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 () => '' });
const mkClient = (over: Partial<Record<'isAvailable' | 'apiCall' | 'getConfig' | 'getClientTokenForHealthCheck', jest.Mock>> = {}) =>
({
isAvailable: over.isAvailable ?? jest.fn().mockResolvedValue(true),
apiCall: over.apiCall ?? jest.fn(),
getConfig: over.getConfig ?? jest.fn().mockReturnValue(cfg),
getClientTokenForHealthCheck: over.getClientTokenForHealthCheck ?? jest.fn().mockResolvedValue('hc-tok'),
}) as any;
const cursor = <T>(items: T[]) => ({ toArray: () => Promise.resolve(items) });
const asyncIterable = <T>(items: T[]) => ({
[Symbol.asyncIterator]: () => {
let i = 0;
return {
next: () => Promise.resolve(i < items.length ? { value: items[i++], done: false } : { value: undefined, done: true }),
};
},
});
const user = (
over: Partial<{ _id: string; username: string; emails: { address: string; verified?: boolean }[]; __rooms: string[] }> = {},
) => ({
_id: 'u1',
username: 'bob',
emails: [{ address: 'bob@x.com', verified: true }],
...over,
});
beforeEach(() => {
serverFetchMock.mockReset();
usersFindOneById.mockReset();
usersFindByUsernames.mockReset();
usersFindActiveByRoomIds.mockReset();
roomsFindPrivateRoomsByIdsWithAbacAttributes.mockReset();
});
const permitFor = (ids: string[]) => ({
decisionResponses: [
{
resourceDecisions: ids.map((id) => ({ ephemeralResourceId: id, decision: 'DECISION_PERMIT' })),
},
],
});
const denyFor = (ids: string[]) => ({
decisionResponses: [
{
resourceDecisions: ids.map((id) => ({ ephemeralResourceId: id, decision: 'DECISION_DENY' })),
},
],
});
describe('VirtruPDP.isAvailable', () => {
it('delegates to client.isAvailable', async () => {
const isAvailable = jest.fn().mockResolvedValue(true);
const pdp = new VirtruPDP(mkClient({ isAvailable }));
expect(await pdp.isAvailable()).toBe(true);
expect(isAvailable).toHaveBeenCalled();
});
});
describe('getDeniedSubjects', () => {
const pair = (userId: string) => ({ user: { _id: userId }, room: { _id: 'r1' } });
const [a, b, c] = [pair('a'), pair('b'), pair('c')];
const resp = (...decisions: Decision[]) => ({
resourceDecisions: decisions.map((decision) => ({ ephemeralResourceId: 'r1', decision })),
});
it('collects subjects with an explicit DENY decision', () => {
const responses = [resp('DECISION_DENY'), resp('DECISION_PERMIT'), resp('DECISION_DENY')];
expect(getDeniedSubjects(responses, [a, b, c])).toEqual([a, c]);
});
it('skips subjects when every decision is PERMIT', () => {
expect(getDeniedSubjects([resp('DECISION_PERMIT')], [a])).toEqual([]);
});
it('skips inconclusive subjects (UNSPECIFIED, empty decisions, missing response)', () => {
expect(getDeniedSubjects([resp('DECISION_UNSPECIFIED'), resp(), undefined], [a, b, c])).toEqual([]);
});
it('treats a DENY among PERMITs in one response as denied', () => {
expect(getDeniedSubjects([resp('DECISION_PERMIT', 'DECISION_DENY')], [a])).toEqual([a]);
});
it('ignores responses without a matching subject', () => {
expect(getDeniedSubjects([resp('DECISION_DENY'), resp('DECISION_DENY')], [a])).toEqual([a]);
});
});
describe('VirtruPDP.canAccessObject', () => {
const room = { _id: 'r1', abacAttributes: [{ key: 'clearance', values: ['secret'] }] };
it('grants when room has no abac attributes (no DB or API call)', async () => {
const apiCall = jest.fn();
const pdp = new VirtruPDP(mkClient({ apiCall }));
const r = await pdp.canAccessObject({ _id: 'r0', abacAttributes: [] } as any, { _id: 'u1' });
expect(r).toEqual({ granted: true });
expect(usersFindOneById).not.toHaveBeenCalled();
expect(apiCall).not.toHaveBeenCalled();
});
it('denies when DB has no such user', async () => {
usersFindOneById.mockResolvedValue(null);
const apiCall = jest.fn();
const pdp = new VirtruPDP(mkClient({ apiCall }));
const r = await pdp.canAccessObject(room as any, { _id: 'u1' });
expect(r).toEqual({ granted: false });
expect(apiCall).not.toHaveBeenCalled();
});
it('denies when user has no entity key (no API call)', async () => {
usersFindOneById.mockResolvedValue(user({ emails: [] }));
const apiCall = jest.fn();
const pdp = new VirtruPDP(mkClient({ apiCall }));
const r = await pdp.canAccessObject(room as any, { _id: 'u1' });
expect(r).toEqual({ granted: false });
expect(apiCall).not.toHaveBeenCalled();
});
it('grants on DECISION_PERMIT and forwards entity + fqn to client', async () => {
usersFindOneById.mockResolvedValue(user());
const apiCall = jest.fn().mockResolvedValue(permitFor(['r1']));
const pdp = new VirtruPDP(mkClient({ apiCall }));
const r = await pdp.canAccessObject(room as any, { _id: 'u1' });
expect(r).toEqual({ granted: true });
const [endpoint, body] = apiCall.mock.calls[0];
expect(endpoint).toBe('/authorization.v2.AuthorizationService/GetDecisionBulk');
expect(body.decisionRequests[0].entityIdentifier.entityChain.entities[0]).toEqual({ emailAddress: 'bob@x.com' });
expect(body.decisionRequests[0].resources[0]).toEqual({
ephemeralId: 'r1',
attributeValues: { fqns: ['https://example.com/attr/clearance/value/secret'] },
});
});
it('denies with userToRemove on DECISION_DENY', async () => {
const fullUser = user();
usersFindOneById.mockResolvedValue(fullUser);
const apiCall = jest.fn().mockResolvedValue(denyFor(['r1']));
const pdp = new VirtruPDP(mkClient({ apiCall }));
const r = await pdp.canAccessObject(room as any, { _id: 'u1' });
expect(r.granted).toBe(false);
expect(r.userToRemove).toBe(fullUser);
});
it('denies WITHOUT userToRemove on inconclusive decision', async () => {
usersFindOneById.mockResolvedValue(user());
const apiCall = jest.fn().mockResolvedValue({ decisionResponses: [{ resourceDecisions: [{ decision: 'DECISION_UNSPECIFIED' }] }] });
const pdp = new VirtruPDP(mkClient({ apiCall }));
const r = await pdp.canAccessObject(room as any, { _id: 'u1' });
expect(r).toEqual({ granted: false });
});
it('denies WITHOUT userToRemove when response has no resourceDecisions', async () => {
usersFindOneById.mockResolvedValue(user());
const apiCall = jest.fn().mockResolvedValue({ decisionResponses: [{}] });
const pdp = new VirtruPDP(mkClient({ apiCall }));
const r = await pdp.canAccessObject(room as any, { _id: 'u1' });
expect(r).toEqual({ granted: false });
});
it('uses oidcIdentifier entity shape when defaultEntityKey is oidcIdentifier', async () => {
usersFindOneById.mockResolvedValue(user());
const apiCall = jest.fn().mockResolvedValue(permitFor(['r1']));
const oidcCfg = { ...cfg, defaultEntityKey: 'oidcIdentifier' as const };
const pdp = new VirtruPDP(mkClient({ apiCall, getConfig: jest.fn().mockReturnValue(oidcCfg) }));
await pdp.canAccessObject(room as any, { _id: 'u1' });
const [, body] = apiCall.mock.calls[0];
expect(body.decisionRequests[0].entityIdentifier.entityChain.entities[0]).toEqual({ id: 'bob' });
});
});
describe('VirtruPDP.checkUsernamesMatchAttributes', () => {
const room = { _id: 'r1' } as any;
const attrs = [{ key: 'clearance', values: ['secret'] }];
it('returns without API when usernames is empty', async () => {
const apiCall = jest.fn();
const pdp = new VirtruPDP(mkClient({ apiCall }));
await pdp.checkUsernamesMatchAttributes([], attrs, room);
expect(apiCall).not.toHaveBeenCalled();
expect(usersFindByUsernames).not.toHaveBeenCalled();
});
it('returns without API when attributes is empty', async () => {
const apiCall = jest.fn();
const pdp = new VirtruPDP(mkClient({ apiCall }));
await pdp.checkUsernamesMatchAttributes(['bob'], [], room);
expect(apiCall).not.toHaveBeenCalled();
});
it('throws when any user has no entity key', async () => {
usersFindByUsernames.mockReturnValue(cursor([user({ emails: [] })]));
const apiCall = jest.fn();
const pdp = new VirtruPDP(mkClient({ apiCall }));
await expect(pdp.checkUsernamesMatchAttributes(['bob'], attrs, room)).rejects.toBeInstanceOf(OnlyCompliantCanBeAddedToRoomError);
expect(apiCall).not.toHaveBeenCalled();
});
it('throws when zero decision requests can be built (all users filtered)', async () => {
usersFindByUsernames.mockReturnValue(cursor([]));
const apiCall = jest.fn();
const pdp = new VirtruPDP(mkClient({ apiCall }));
await expect(pdp.checkUsernamesMatchAttributes(['ghost'], attrs, room)).rejects.toBeInstanceOf(OnlyCompliantCanBeAddedToRoomError);
expect(apiCall).not.toHaveBeenCalled();
});
it('resolves when all users get DECISION_PERMIT', async () => {
usersFindByUsernames.mockReturnValue(
cursor([user(), user({ _id: 'u2', username: 'alice', emails: [{ address: 'a@x.com', verified: true }] })]),
);
const apiCall = jest.fn().mockResolvedValue({
decisionResponses: [
{ resourceDecisions: [{ ephemeralResourceId: 'r1', decision: 'DECISION_PERMIT' }] },
{ resourceDecisions: [{ ephemeralResourceId: 'r1', decision: 'DECISION_PERMIT' }] },
],
});
const pdp = new VirtruPDP(mkClient({ apiCall }));
await expect(pdp.checkUsernamesMatchAttributes(['bob', 'alice'], attrs, room)).resolves.toBeUndefined();
});
it('throws when any user is DENIED', async () => {
usersFindByUsernames.mockReturnValue(
cursor([user(), user({ _id: 'u2', username: 'alice', emails: [{ address: 'a@x.com', verified: true }] })]),
);
const apiCall = jest.fn().mockResolvedValue({
decisionResponses: [
{ resourceDecisions: [{ ephemeralResourceId: 'r1', decision: 'DECISION_PERMIT' }] },
{ resourceDecisions: [{ ephemeralResourceId: 'r1', decision: 'DECISION_DENY' }] },
],
});
const pdp = new VirtruPDP(mkClient({ apiCall }));
await expect(pdp.checkUsernamesMatchAttributes(['bob', 'alice'], attrs, room)).rejects.toBeInstanceOf(
OnlyCompliantCanBeAddedToRoomError,
);
});
it('throws when a decision response is empty (no resourceDecisions)', async () => {
usersFindByUsernames.mockReturnValue(cursor([user()]));
const apiCall = jest.fn().mockResolvedValue({ decisionResponses: [{}] });
const pdp = new VirtruPDP(mkClient({ apiCall }));
await expect(pdp.checkUsernamesMatchAttributes(['bob'], attrs, room)).rejects.toBeInstanceOf(OnlyCompliantCanBeAddedToRoomError);
});
});
describe('VirtruPDP.onRoomAttributesChanged', () => {
const room = { _id: 'r1', t: 'p', teamMain: false, abacAttributes: [] } as any;
const newAttrs = [{ key: 'clearance', values: ['secret'] }];
it('returns [] when no new attributes (no DB scan)', async () => {
const apiCall = jest.fn();
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.onRoomAttributesChanged(room, []);
expect(result).toEqual([]);
expect(usersFindActiveByRoomIds).not.toHaveBeenCalled();
});
it('returns user as non-compliant when entity key missing (no API request for that user)', async () => {
const u = user({ emails: [] });
usersFindActiveByRoomIds.mockReturnValue(asyncIterable([u]));
const apiCall = jest.fn();
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.onRoomAttributesChanged(room, newAttrs);
expect(result).toEqual([u]);
expect(apiCall).not.toHaveBeenCalled();
});
it('returns [] when all users are DECISION_PERMIT', async () => {
usersFindActiveByRoomIds.mockReturnValue(asyncIterable([user()]));
const apiCall = jest.fn().mockResolvedValue(permitFor(['r1']));
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.onRoomAttributesChanged(room, newAttrs);
expect(result).toEqual([]);
});
it('returns users that are DENIED', async () => {
const u1 = user();
const u2 = user({ _id: 'u2', username: 'alice', emails: [{ address: 'a@x.com', verified: true }] });
usersFindActiveByRoomIds.mockReturnValue(asyncIterable([u1, u2]));
const apiCall = jest.fn().mockResolvedValue({
decisionResponses: [
{ resourceDecisions: [{ ephemeralResourceId: 'r1', decision: 'DECISION_PERMIT' }] },
{ resourceDecisions: [{ ephemeralResourceId: 'r1', decision: 'DECISION_DENY' }] },
],
});
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.onRoomAttributesChanged(room, newAttrs);
expect(result).toEqual([u2]);
});
it('does not evict on empty resourceDecisions (inconclusive)', async () => {
const u = user();
usersFindActiveByRoomIds.mockReturnValue(asyncIterable([u]));
const apiCall = jest.fn().mockResolvedValue({ decisionResponses: [{ resourceDecisions: [] }] });
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.onRoomAttributesChanged(room, newAttrs);
expect(result).toEqual([]);
});
it('does not evict on DECISION_UNSPECIFIED (inconclusive)', async () => {
usersFindActiveByRoomIds.mockReturnValue(asyncIterable([user()]));
const apiCall = jest.fn().mockResolvedValue({
decisionResponses: [{ resourceDecisions: [{ ephemeralResourceId: 'r1', decision: 'DECISION_UNSPECIFIED' }] }],
});
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.onRoomAttributesChanged(room, newAttrs);
expect(result).toEqual([]);
});
it('still evicts entity-keyless users alongside DENY-only filtering of decided users', async () => {
const keyless = user({ _id: 'u0', emails: [] });
const denied = user({ _id: 'u2', username: 'alice', emails: [{ address: 'a@x.com', verified: true }] });
const unspecified = user({ _id: 'u3', username: 'carol', emails: [{ address: 'c@x.com', verified: true }] });
usersFindActiveByRoomIds.mockReturnValue(asyncIterable([keyless, denied, unspecified]));
const apiCall = jest.fn().mockResolvedValue({
decisionResponses: [
{ resourceDecisions: [{ ephemeralResourceId: 'r1', decision: 'DECISION_DENY' }] },
{ resourceDecisions: [{ ephemeralResourceId: 'r1', decision: 'DECISION_UNSPECIFIED' }] },
],
});
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.onRoomAttributesChanged(room, newAttrs);
expect(result).toEqual([keyless, denied]);
});
it('returns only entity-keyless users when ALL users lack entity keys (no API call)', async () => {
const u = user({ emails: [] });
usersFindActiveByRoomIds.mockReturnValue(asyncIterable([u]));
const apiCall = jest.fn();
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.onRoomAttributesChanged(room, newAttrs);
expect(result).toEqual([u]);
expect(apiCall).not.toHaveBeenCalled();
});
});
describe('VirtruPDP.onSubjectAttributesChanged', () => {
it('returns [] when user has no __rooms', async () => {
const pdp = new VirtruPDP(mkClient());
const result = await pdp.onSubjectAttributesChanged(user() as any, []);
expect(result).toEqual([]);
expect(roomsFindPrivateRoomsByIdsWithAbacAttributes).not.toHaveBeenCalled();
});
it('returns [] when no ABAC rooms among __rooms', async () => {
roomsFindPrivateRoomsByIdsWithAbacAttributes.mockReturnValue(cursor([]));
const pdp = new VirtruPDP(mkClient());
const result = await pdp.onSubjectAttributesChanged(user({ __rooms: ['r1'] }) as any, []);
expect(result).toEqual([]);
});
it('returns ALL abac rooms when user has no entity key (no API call)', async () => {
const rooms = [{ _id: 'r1', abacAttributes: [{ key: 'k', values: ['v'] }] }];
roomsFindPrivateRoomsByIdsWithAbacAttributes.mockReturnValue(cursor(rooms));
const apiCall = jest.fn();
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.onSubjectAttributesChanged(user({ __rooms: ['r1'], emails: [] }) as any, []);
expect(result).toEqual([{ _id: 'r1' }]);
expect(apiCall).not.toHaveBeenCalled();
});
it('returns only DENIED rooms when mixed decisions come back', async () => {
const rooms = [
{ _id: 'rP', abacAttributes: [{ key: 'k', values: ['v'] }] },
{ _id: 'rD', abacAttributes: [{ key: 'k', values: ['v'] }] },
];
roomsFindPrivateRoomsByIdsWithAbacAttributes.mockReturnValue(cursor(rooms));
const apiCall = jest.fn().mockResolvedValue({
decisionResponses: [
{ resourceDecisions: [{ ephemeralResourceId: 'rP', decision: 'DECISION_PERMIT' }] },
{ resourceDecisions: [{ ephemeralResourceId: 'rD', decision: 'DECISION_DENY' }] },
],
});
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.onSubjectAttributesChanged(user({ __rooms: ['rP', 'rD'] }) as any, []);
expect(result).toEqual([{ _id: 'rD' }]);
});
it('still treats DECISION_UNSPECIFIED as non-compliant (LDAP-driven path cannot yield inconclusive decisions)', async () => {
const rooms = [{ _id: 'r1', abacAttributes: [{ key: 'k', values: ['v'] }] }];
roomsFindPrivateRoomsByIdsWithAbacAttributes.mockReturnValue(cursor(rooms));
const apiCall = jest.fn().mockResolvedValue({
decisionResponses: [{ resourceDecisions: [{ ephemeralResourceId: 'r1', decision: 'DECISION_UNSPECIFIED' }] }],
});
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.onSubjectAttributesChanged(user({ __rooms: ['r1'] }) as any, []);
expect(result).toEqual([{ _id: 'r1' }]);
});
it('splits >200 rooms into multiple decision batches', async () => {
const total = 401;
const rooms = Array.from({ length: total }, (_, i) => ({ _id: `r${i}`, abacAttributes: [{ key: 'k', values: ['v'] }] }));
roomsFindPrivateRoomsByIdsWithAbacAttributes.mockReturnValue(cursor(rooms));
const apiCall = jest.fn().mockImplementation(async (_endpoint, body: any) => ({
decisionResponses: body.decisionRequests.map((r: any) => ({
resourceDecisions: [{ ephemeralResourceId: r.resources[0].ephemeralId, decision: 'DECISION_PERMIT' }],
})),
}));
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.onSubjectAttributesChanged(user({ __rooms: rooms.map((r) => r._id) }) as any, []);
expect(result).toEqual([]);
expect(apiCall).toHaveBeenCalledTimes(3);
const sizes = apiCall.mock.calls.map(([, body]: any[]) => body.decisionRequests.length);
expect(sizes.sort((a, b) => a - b)).toEqual([1, 200, 200]);
});
});
describe('VirtruPDP.evaluateUserRooms', () => {
it('returns [] when entries is empty', async () => {
const apiCall = jest.fn();
const pdp = new VirtruPDP(mkClient({ apiCall }));
expect(await pdp.evaluateUserRooms([])).toEqual([]);
expect(apiCall).not.toHaveBeenCalled();
});
it('flags every room of a user lacking entity key, with no API call', async () => {
const u = user({ emails: [] });
const apiCall = jest.fn();
const pdp = new VirtruPDP(mkClient({ apiCall }));
const rooms = [
{ _id: 'r1', abacAttributes: [{ key: 'k', values: ['v'] }] },
{ _id: 'r2', abacAttributes: [{ key: 'k', values: ['v'] }] },
];
const result = await pdp.evaluateUserRooms([{ user: u, rooms }]);
expect(result).toEqual([
{ user: u, room: rooms[0] },
{ user: u, room: rooms[1] },
]);
expect(apiCall).not.toHaveBeenCalled();
});
it('returns [] when every entry+room PERMITs', async () => {
const u = user();
const rooms = [{ _id: 'r1', abacAttributes: [{ key: 'k', values: ['v'] }] }];
const apiCall = jest.fn().mockResolvedValue(permitFor(['r1']));
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.evaluateUserRooms([{ user: u, rooms }]);
expect(result).toEqual([]);
});
it('returns non-compliant (user,room) pairs when a decision DENYs', async () => {
const u = user();
const rooms = [
{ _id: 'rP', abacAttributes: [{ key: 'k', values: ['v'] }] },
{ _id: 'rD', abacAttributes: [{ key: 'k', values: ['v'] }] },
];
const apiCall = jest.fn().mockResolvedValue({
decisionResponses: [
{ resourceDecisions: [{ ephemeralResourceId: 'rP', decision: 'DECISION_PERMIT' }] },
{ resourceDecisions: [{ ephemeralResourceId: 'rD', decision: 'DECISION_DENY' }] },
],
});
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.evaluateUserRooms([{ user: u, rooms }]);
expect(result).toEqual([{ user: u, room: rooms[1] }]);
});
it('does not evict on empty resourceDecisions (inconclusive)', async () => {
const u = user();
const rooms = [{ _id: 'r1', abacAttributes: [{ key: 'k', values: ['v'] }] }];
const apiCall = jest.fn().mockResolvedValue({ decisionResponses: [{ resourceDecisions: [] }] });
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.evaluateUserRooms([{ user: u, rooms }]);
expect(result).toEqual([]);
});
it('does not evict on DECISION_UNSPECIFIED (inconclusive)', async () => {
const u = user();
const rooms = [{ _id: 'r1', abacAttributes: [{ key: 'k', values: ['v'] }] }];
const apiCall = jest.fn().mockResolvedValue({
decisionResponses: [{ resourceDecisions: [{ ephemeralResourceId: 'r1', decision: 'DECISION_UNSPECIFIED' }] }],
});
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.evaluateUserRooms([{ user: u, rooms }]);
expect(result).toEqual([]);
});
it('evicts only explicit DENY among mixed PERMIT/UNSPECIFIED/DENY decisions', async () => {
const u = user();
const rooms = [
{ _id: 'rP', abacAttributes: [{ key: 'k', values: ['v'] }] },
{ _id: 'rU', abacAttributes: [{ key: 'k', values: ['v'] }] },
{ _id: 'rD', abacAttributes: [{ key: 'k', values: ['v'] }] },
];
const apiCall = jest.fn().mockResolvedValue({
decisionResponses: [
{ resourceDecisions: [{ ephemeralResourceId: 'rP', decision: 'DECISION_PERMIT' }] },
{ resourceDecisions: [{ ephemeralResourceId: 'rU', decision: 'DECISION_UNSPECIFIED' }] },
{ resourceDecisions: [{ ephemeralResourceId: 'rD', decision: 'DECISION_DENY' }] },
],
});
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.evaluateUserRooms([{ user: u, rooms }]);
expect(result).toEqual([{ user: u, room: rooms[2] }]);
});
it('rejects when the decision call fails (no evictions, sweep retried next run)', async () => {
const apiCall = jest.fn().mockRejectedValue(new Error('pdp down'));
const pdp = new VirtruPDP(mkClient({ apiCall }));
const rooms = [{ _id: 'r1', abacAttributes: [{ key: 'k', values: ['v'] }] }];
await expect(pdp.evaluateUserRooms([{ user: user(), rooms }])).rejects.toThrow('pdp down');
expect(apiCall).toHaveBeenCalled();
});
});
describe('VirtruPDP — PDP unreachable (decision call rejects)', () => {
const pdpDown = () => jest.fn().mockRejectedValue(new Error('pdp down'));
const room = { _id: 'r1', abacAttributes: [{ key: 'clearance', values: ['secret'] }] } as any;
const attrs = [{ key: 'clearance', values: ['secret'] }];
it('canAccessObject rejects (never silently grants) when the decision call fails', async () => {
usersFindOneById.mockResolvedValue(user());
const apiCall = pdpDown();
const pdp = new VirtruPDP(mkClient({ apiCall }));
await expect(pdp.canAccessObject(room, { _id: 'u1' })).rejects.toThrow('pdp down');
expect(apiCall).toHaveBeenCalled();
});
it('checkUsernamesMatchAttributes rejects (never silently admits) when the decision call fails', async () => {
usersFindByUsernames.mockReturnValue(cursor([user()]));
const apiCall = pdpDown();
const pdp = new VirtruPDP(mkClient({ apiCall }));
await expect(pdp.checkUsernamesMatchAttributes(['bob'], attrs, room)).rejects.toThrow('pdp down');
expect(apiCall).toHaveBeenCalled();
});
it('onRoomAttributesChanged rejects (never silently keeps everyone) when the decision call fails', async () => {
usersFindActiveByRoomIds.mockReturnValue(asyncIterable([user()]));
const apiCall = pdpDown();
const pdp = new VirtruPDP(mkClient({ apiCall }));
await expect(pdp.onRoomAttributesChanged(room, attrs)).rejects.toThrow('pdp down');
expect(apiCall).toHaveBeenCalled();
});
it('onSubjectAttributesChanged rejects when the decision call fails', async () => {
roomsFindPrivateRoomsByIdsWithAbacAttributes.mockReturnValue(cursor([room]));
const apiCall = pdpDown();
const pdp = new VirtruPDP(mkClient({ apiCall }));
await expect(pdp.onSubjectAttributesChanged(user({ __rooms: ['r1'] }) as any, [])).rejects.toThrow('pdp down');
expect(apiCall).toHaveBeenCalled();
});
});
describe('reevaluateUsers', () => {
const room = { _id: 'r1', abacAttributes: [{ key: 'clearance', values: ['secret'] }] };
it('returns no pairs when users have no ABAC rooms', async () => {
const pdp = new VirtruPDP(mkClient());
const result = await pdp.reevaluateUsers([user({ _id: 'u1', __rooms: [] })]);
expect(result).toEqual([]);
expect(roomsFindPrivateRoomsByIdsWithAbacAttributes).not.toHaveBeenCalled();
});
it('returns non-compliant {user, room} pairs for denied users', async () => {
roomsFindPrivateRoomsByIdsWithAbacAttributes.mockReturnValue(asyncIterable([room]));
const apiCall = jest.fn().mockResolvedValue(denyFor(['r1']));
const pdp = new VirtruPDP(mkClient({ apiCall }));
const u = user({ _id: 'u1', __rooms: ['r1'] });
const result = await pdp.reevaluateUsers([u]);
expect(result).toEqual([{ user: u, room }]);
});
it('returns no pairs when the user is permitted', async () => {
roomsFindPrivateRoomsByIdsWithAbacAttributes.mockReturnValue(asyncIterable([room]));
const apiCall = jest.fn().mockResolvedValue(permitFor(['r1']));
const pdp = new VirtruPDP(mkClient({ apiCall }));
const result = await pdp.reevaluateUsers([user({ _id: 'u1', __rooms: ['r1'] })]);
expect(result).toEqual([]);
});
});
describe('VirtruPDP.getHealthStatus', () => {
const platformOk = () => okJson({ status: 'SERVING' });
const authOk = () => okJson({});
it('passes when platform SERVING, IdP token resolves, authorization OK', async () => {
serverFetchMock.mockResolvedValueOnce(platformOk()).mockResolvedValueOnce(authOk());
const pdp = new VirtruPDP(mkClient());
await expect(pdp.getHealthStatus()).resolves.toBeUndefined();
});
it('throws Platform_Failed when platform healthz returns non-ok HTTP', async () => {
serverFetchMock.mockResolvedValueOnce({ ok: false, status: 503, json: async () => ({}) });
const pdp = new VirtruPDP(mkClient());
await expect(pdp.getHealthStatus()).rejects.toMatchObject({ message: 'ABAC_PDP_Health_Platform_Failed' });
});
it('throws Platform_Failed when platform healthz status is not SERVING', async () => {
serverFetchMock.mockResolvedValueOnce(okJson({ status: 'NOT_SERVING' }));
const pdp = new VirtruPDP(mkClient());
await expect(pdp.getHealthStatus()).rejects.toBeInstanceOf(PdpHealthCheckError);
});
it('throws Platform_Failed when platform healthz status is missing', async () => {
serverFetchMock.mockResolvedValueOnce(okJson({}));
const pdp = new VirtruPDP(mkClient());
await expect(pdp.getHealthStatus()).rejects.toMatchObject({ message: 'ABAC_PDP_Health_Platform_Failed' });
});
it('throws Platform_Failed when serverFetch itself rejects', async () => {
serverFetchMock.mockRejectedValueOnce(new Error('network down'));
const pdp = new VirtruPDP(mkClient());
await expect(pdp.getHealthStatus()).rejects.toMatchObject({ message: 'ABAC_PDP_Health_Platform_Failed' });
});
it('throws IdP_Failed when token fetch errors', async () => {
serverFetchMock.mockResolvedValueOnce(platformOk());
const getClientTokenForHealthCheck = jest.fn().mockRejectedValue(new Error('idp down'));
const pdp = new VirtruPDP(mkClient({ getClientTokenForHealthCheck }));
await expect(pdp.getHealthStatus()).rejects.toMatchObject({ message: 'ABAC_PDP_Health_IdP_Failed' });
});
it('throws Authorization_Failed when authorization endpoint returns non-ok HTTP', async () => {
serverFetchMock.mockResolvedValueOnce(platformOk()).mockResolvedValueOnce({ ok: false, status: 403, json: async () => ({}) });
const pdp = new VirtruPDP(mkClient());
await expect(pdp.getHealthStatus()).rejects.toMatchObject({ message: 'ABAC_PDP_Health_Authorization_Failed' });
});
it('throws Authorization_Failed when authorization fetch rejects', async () => {
serverFetchMock.mockResolvedValueOnce(platformOk()).mockRejectedValueOnce(new Error('auth down'));
const pdp = new VirtruPDP(mkClient());
await expect(pdp.getHealthStatus()).rejects.toMatchObject({ message: 'ABAC_PDP_Health_Authorization_Failed' });
});
it('sends Bearer token to authorization endpoint', async () => {
serverFetchMock.mockResolvedValueOnce(platformOk()).mockResolvedValueOnce(authOk());
const pdp = new VirtruPDP(mkClient());
await pdp.getHealthStatus();
const authCall = serverFetchMock.mock.calls.find(([url]) => String(url).includes('GetEntitlements'));
expect((authCall?.[1] as any).headers.Authorization).toBe('Bearer hc-tok');
});
});

View File

@@ -0,0 +1,442 @@
import type { IAbacAttributeDefinition, IRoom, IUser, AtLeast } from '@rocket.chat/core-typings';
import { Rooms, Users } from '@rocket.chat/models';
import { serverFetch } from '@rocket.chat/server-fetch';
import { isTruthy } from '@rocket.chat/tools';
import pLimit from 'p-limit';
import type {
IPolicyDecisionPoint,
IGetDecisionBulkRequest,
IGetDecisionBulkResponse,
IResourceDecision,
NonCompliantPair,
ReevaluationUser,
} 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';
import { OnlyCompliantCanBeAddedToRoomError, PdpHealthCheckError } from '../errors';
import { logger } from '../logger';
const pdpLogger = logger.section('VirtruPDP');
export const getDeniedSubjects = <T extends { user: Pick<IUser, '_id'>; room: Pick<IRoom, '_id'> }>(
responses: Array<{ resourceDecisions?: IResourceDecision[] } | undefined>,
subjects: T[],
): T[] => {
return subjects.filter((subject, index) => {
const decisions = responses[index]?.resourceDecisions ?? [];
if (decisions.some((rd) => rd.decision === 'DECISION_DENY')) {
return true;
}
if (!decisions.length || decisions.some((rd) => rd.decision !== 'DECISION_PERMIT')) {
pdpLogger.warn({ msg: 'Inconclusive PDP decision, eviction skipped', rid: subject.room._id, userId: subject.user._id });
}
return false;
});
};
export class VirtruPDP implements IPolicyDecisionPoint {
private client: VirtruClient;
constructor(client: VirtruClient) {
this.client = client;
}
async isAvailable(): Promise<boolean> {
return this.client.isAvailable();
}
async getHealthStatus(): Promise<void> {
await this.checkPlatformHealth();
const token = await this.checkIdpConnectivity();
await this.checkAuthorizedAccess(token);
pdpLogger.info({ msg: 'Virtru PDP health check passed' });
}
private async checkIdpConnectivity(): Promise<string> {
try {
const token = await this.client.getClientTokenForHealthCheck();
pdpLogger.info({ msg: 'Virtru PDP health check: IdP connectivity OK' });
return token;
} catch (err) {
pdpLogger.warn({ msg: 'Virtru PDP health check: IdP connectivity failed', err });
throw new PdpHealthCheckError('ABAC_PDP_Health_IdP_Failed');
}
}
private async checkPlatformHealth(): Promise<void> {
const config = this.client.getConfig();
try {
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.
ignoreSsrfValidation: true,
});
if (!response.ok) {
throw new Error(`Platform healthz returned HTTP ${response.status}`);
}
const data = (await response.json()) as { status?: string };
if (data.status !== 'SERVING') {
throw new Error(`Platform healthz status is '${data.status ?? 'unknown'}'`);
}
pdpLogger.info({ msg: 'Virtru PDP health check: platform OK', status: data.status });
} catch (err) {
pdpLogger.warn({ msg: 'Virtru PDP health check: platform failed', err });
throw new PdpHealthCheckError('ABAC_PDP_Health_Platform_Failed');
}
}
private async checkAuthorizedAccess(token: string): Promise<void> {
const config = this.client.getConfig();
try {
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: 'health-check', clientId: config.clientId }] } },
}),
// 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(`Authorization endpoint returned HTTP ${response.status}`);
}
pdpLogger.info({ msg: 'Virtru PDP health check: authorization OK' });
} catch (err) {
pdpLogger.warn({ msg: 'Virtru PDP health check: authorization failed', err });
throw new PdpHealthCheckError('ABAC_PDP_Health_Authorization_Failed');
}
}
private async getDecisionBulk(
requests: Array<IGetDecisionBulkRequest | null>,
): Promise<Array<{ resourceDecisions?: IResourceDecision[] } | undefined>> {
const BATCH_SIZE = 200;
const limit = pLimit(4);
const batches: Array<(IGetDecisionBulkRequest | null)[]> = [];
for (let i = 0; i < requests.length; i += BATCH_SIZE) {
batches.push(requests.slice(i, i + BATCH_SIZE));
}
const batchResults = await Promise.all(
batches.map((batch, batchIndex) =>
limit(async (): Promise<Array<{ resourceDecisions?: IResourceDecision[] } | undefined>> => {
const validBatch = batch.filter(Boolean);
if (!validBatch.length) {
return batch.map(() => undefined);
}
const result = await this.client.apiCall<IGetDecisionBulkResponse>('/authorization.v2.AuthorizationService/GetDecisionBulk', {
decisionRequests: validBatch,
});
pdpLogger.debug({ msg: 'GetDecisionBulk response', batch: batchIndex + 1, result });
const responses = result.decisionResponses ?? [];
let responseIdx = 0;
return batch.map((req) => (req ? responses[responseIdx++] : undefined));
}),
),
);
return batchResults.flat();
}
async canAccessObject(
room: AtLeast<IRoom, '_id' | 'abacAttributes'>,
user: AtLeast<IUser, '_id'>,
): Promise<{ granted: boolean; userToRemove?: IUser }> {
const attributes = room.abacAttributes ?? [];
if (!attributes.length) {
return { granted: true };
}
const config = this.client.getConfig();
const fullUser = await Users.findOneById(user._id);
if (!fullUser) {
return { granted: false };
}
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 };
}
const responses = await this.getDecisionBulk([
{
entityIdentifier: {
entityChain: {
entities: [buildEntityIdentifier(config.defaultEntityKey, entityKey)],
},
},
action: { name: 'read' },
resources: [
{
ephemeralId: room._id,
attributeValues: { fqns: buildAttributeFqns(config.attributeNamespace, 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 });
return { granted: true };
}
if (decision === 'DECISION_DENY') {
pdpLogger.debug({ msg: 'Virtru PDP canAccessObject: denied', roomId: room._id, userId: user._id });
return { granted: false, userToRemove: fullUser };
}
pdpLogger.debug({
msg: 'Virtru PDP canAccessObject: inconclusive decision, denying without removal',
roomId: room._id,
userId: user._id,
decision,
});
return { granted: false };
}
async checkUsernamesMatchAttributes(usernames: string[], attributes: IAbacAttributeDefinition[], object: IRoom): Promise<void> {
if (!usernames.length || !attributes.length) {
return;
}
const config = this.client.getConfig();
const users = await Users.findByUsernames(usernames, { projection: { _id: 1, emails: 1, username: 1 } }).toArray();
const fqns = buildAttributeFqns(config.attributeNamespace, attributes);
const decisionRequests: IGetDecisionBulkRequest[] = [];
for (const user of users) {
const entityKey = getUserEntityKey(config.defaultEntityKey, user);
if (!entityKey) {
throw new OnlyCompliantCanBeAddedToRoomError();
}
decisionRequests.push({
entityIdentifier: {
entityChain: {
entities: [buildEntityIdentifier(config.defaultEntityKey, entityKey)],
},
},
action: { name: 'read' },
resources: [
{
ephemeralId: object._id,
attributeValues: { fqns },
},
],
});
}
if (!decisionRequests.length) {
throw new OnlyCompliantCanBeAddedToRoomError();
}
const responses = await this.getDecisionBulk(decisionRequests);
const hasNonCompliant = responses.some(
(resp) => !resp?.resourceDecisions?.length || resp.resourceDecisions.some((rd) => rd.decision !== 'DECISION_PERMIT'),
);
if (hasNonCompliant) {
throw new OnlyCompliantCanBeAddedToRoomError();
}
}
async onRoomAttributesChanged(
room: AtLeast<IRoom, '_id' | 't' | 'teamMain' | 'abacAttributes'>,
newAttributes: IAbacAttributeDefinition[],
): Promise<IUser[]> {
if (!newAttributes.length) {
return [];
}
const users = Users.findActiveByRoomIds([room._id]);
const config = this.client.getConfig();
const nonCompliantUsers: IUser[] = [];
const decisionRequests: IGetDecisionBulkRequest[] = [];
const requestIndex: Array<{ user: IUser; room: typeof room }> = [];
const fqns = buildAttributeFqns(config.attributeNamespace, newAttributes);
for await (const user of users) {
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);
continue;
}
requestIndex.push({ user, room });
decisionRequests.push({
entityIdentifier: {
entityChain: {
entities: [buildEntityIdentifier(config.defaultEntityKey, entityKey)],
},
},
action: { name: 'read' },
resources: [
{
ephemeralId: room._id,
attributeValues: { fqns },
},
],
});
}
if (!decisionRequests.length) {
return nonCompliantUsers;
}
const responses = await this.getDecisionBulk(decisionRequests);
nonCompliantUsers.push(...getDeniedSubjects(responses, requestIndex).map(({ user }) => user));
return nonCompliantUsers;
}
async evaluateUserRooms(
entries: Array<{
user: Pick<IUser, '_id' | 'emails' | 'username'>;
rooms: AtLeast<IRoom, '_id' | 'abacAttributes'>[];
}>,
): Promise<NonCompliantPair[]> {
const requestIndex: NonCompliantPair[] = [];
const allRequests: IGetDecisionBulkRequest[] = [];
const config = this.client.getConfig();
const nonCompliant: NonCompliantPair[] = [];
for (const { user, rooms } of entries) {
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) {
nonCompliant.push({ user, room });
}
continue;
}
for (const room of rooms) {
requestIndex.push({ user, room });
allRequests.push({
entityIdentifier: {
entityChain: {
entities: [buildEntityIdentifier(config.defaultEntityKey, entityKey)],
},
},
action: { name: 'read' },
resources: [
{
ephemeralId: room._id,
attributeValues: { fqns: buildAttributeFqns(config.attributeNamespace, room.abacAttributes ?? []) },
},
],
});
}
}
if (!allRequests.length) {
return nonCompliant;
}
const responses = await this.getDecisionBulk(allRequests);
nonCompliant.push(...getDeniedSubjects(responses, requestIndex));
return nonCompliant;
}
async reevaluateUsers(users: ReevaluationUser[]): Promise<NonCompliantPair[]> {
const roomIds = [...new Set(users.flatMap((u) => u.__rooms ?? []))];
if (!roomIds.length) {
return [];
}
const abacRoomCursor = Rooms.findPrivateRoomsByIdsWithAbacAttributes(roomIds, {
projection: { _id: 1, abacAttributes: 1 },
});
const abacRoomById = new Map<string, Pick<IRoom, '_id' | 'abacAttributes'>>();
for await (const room of abacRoomCursor) {
abacRoomById.set(room._id, room);
}
const entries = users
.map((user) => {
const rooms = (user.__rooms ?? []).map((rid) => abacRoomById.get(rid)).filter(isTruthy);
return rooms.length ? { user, rooms } : null;
})
.filter(isTruthy);
return this.evaluateUserRooms(entries);
}
async onSubjectAttributesChanged(user: IUser, _next: IAbacAttributeDefinition[]): Promise<Pick<IRoom, '_id'>[]> {
const roomIds = user.__rooms;
if (!roomIds?.length) {
return [];
}
const abacRooms = await Rooms.findPrivateRoomsByIdsWithAbacAttributes(roomIds, {
projection: { _id: 1, abacAttributes: 1 },
}).toArray();
if (!abacRooms.length) {
return [];
}
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',
userId: user._id,
});
return abacRooms.map(({ _id }) => ({ _id }));
}
const decisionRequests = abacRooms.map((room) => ({
entityIdentifier: {
entityChain: {
entities: [buildEntityIdentifier(config.defaultEntityKey, entityKey)],
},
},
action: { name: 'read' },
resources: [
{
ephemeralId: room._id,
attributeValues: { fqns: buildAttributeFqns(config.attributeNamespace, room.abacAttributes ?? []) },
},
],
}));
const responses = await this.getDecisionBulk(decisionRequests);
const nonCompliantRooms: Pick<IRoom, '_id'>[] = [];
responses.forEach((resp, index) => {
const permitted = resp?.resourceDecisions?.length && resp.resourceDecisions.every((rd) => rd.decision === 'DECISION_PERMIT');
if (!permitted && abacRooms[index]) {
nonCompliantRooms.push({ _id: abacRooms[index]._id });
}
});
return nonCompliantRooms;
}
}

View File

@@ -0,0 +1,3 @@
export { LocalPDP } from './LocalPDP';
export { VirtruPDP } from './VirtruPDP';
export type { IVirtruPDPConfig as VirtruPDPConfig, IPolicyDecisionPoint } from './types';

View File

@@ -0,0 +1,97 @@
import type { IAbacAttributeDefinition, IRoom, IUser, AtLeast } from '@rocket.chat/core-typings';
export type IEntityIdentifier = { emailAddress: string } | { id: string };
export interface IGetDecisionBulkRequest {
entityIdentifier: {
entityChain: {
entities: IEntityIdentifier[];
};
};
action: { name: string };
resources: Array<{
ephemeralId: string;
attributeValues: { fqns: string[] };
}>;
}
export type Decision = 'DECISION_PERMIT' | 'DECISION_DENY' | 'DECISION_UNSPECIFIED';
export interface IResourceDecision {
decision?: Decision;
ephemeralResourceId?: string;
}
export interface IGetDecisionBulkResponse {
decisionResponses?: Array<{
resourceDecisions?: IResourceDecision[];
}>;
}
export type ReevaluationUser = Pick<IUser, '_id' | 'emails' | 'username' | '__rooms'>;
export type NonCompliantPair = {
user: Pick<IUser, '_id' | 'emails' | 'username'>;
room: AtLeast<IRoom, '_id' | 'abacAttributes'>;
};
export interface IPolicyDecisionPoint {
isAvailable(): Promise<boolean>;
getHealthStatus(): Promise<void>;
canAccessObject(
room: AtLeast<IRoom, '_id' | 'abacAttributes'>,
user: AtLeast<IUser, '_id'>,
): Promise<{ granted: boolean; userToRemove?: IUser }>;
checkUsernamesMatchAttributes(usernames: string[], attributes: IAbacAttributeDefinition[], object: IRoom): Promise<void>;
onRoomAttributesChanged(
room: AtLeast<IRoom, '_id' | 't' | 'teamMain' | 'abacAttributes'>,
newAttributes: IAbacAttributeDefinition[],
): Promise<IUser[]>;
onSubjectAttributesChanged(user: IUser, next: IAbacAttributeDefinition[]): Promise<Pick<IRoom, '_id'>[]>;
evaluateUserRooms(
entries: Array<{
user: Pick<IUser, '_id' | 'emails' | 'username'>;
rooms: AtLeast<IRoom, '_id' | 'abacAttributes'>[];
}>,
): Promise<NonCompliantPair[]>;
reevaluateUsers(users: ReevaluationUser[]): Promise<void | NonCompliantPair[]>;
}
export interface IVirtruPDPConfig {
baseUrl: string;
clientId: string;
clientSecret: string;
oidcEndpoint: string;
defaultEntityKey: 'emailAddress' | 'oidcIdentifier';
attributeNamespace: string;
}
export interface ITokenCache {
accessToken: string;
expiresAt: number;
}
export interface IGetEntitlementsRequest {
entityIdentifier: {
entityChain: {
entities: IEntityIdentifier[];
};
};
withComprehensiveHierarchy: boolean;
}
export interface IEntityEntitlements {
ephemeralId?: string;
actionsPerAttributeValueFqn: Record<string, unknown>;
}
export interface IGetEntitlementsResponse {
entitlements?: IEntityEntitlements[];
}

File diff suppressed because it is too large Load Diff

View File

@@ -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 },
);
});
});

View File

@@ -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/tools';
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<ListAttributesResult> {
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<void> {
await ensureAttributeDefinitionsExist(attrs);
}
async entitlementsOf(_actor: AbacActor): Promise<AttributeEntitlements> {
return new Map();
}
async scopeRoomsPage<T extends Pick<IRoom, '_id' | 'abacAttributes'>>(
rooms: T[],
_actor: AbacActor,
): Promise<Array<T & IRoomAbacRedaction>> {
return rooms;
}
async assertCanModifyRoom(_room: Pick<IRoom, '_id' | 'abacAttributes'>, _actor: AbacActor): Promise<void> {
// nop
}
}

View File

@@ -0,0 +1,301 @@
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<Record<'isAvailable' | 'apiCall' | 'getConfig', jest.Mock>> = {}) =>
({
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', verified: true }], 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', verified: true }], 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('validateAssignable rejects (fail-closed) with PdpUnavailable when Virtru is unreachable', async () => {
const apiCall = jest.fn();
const store = new VirtruAttributeStore(mkClient({ isAvailable: jest.fn().mockResolvedValue(false), apiCall }));
await expect(store.validateAssignable([{ key: 'clearance', values: ['secret'] }], actor)).rejects.toMatchObject({
code: 'error-pdp-unavailable',
});
expect(apiCall).not.toHaveBeenCalled();
});
it('validateAssignable propagates (fail-closed, never silently assigns) when the GetEntitlements call fails', async () => {
const apiCall = jest.fn().mockRejectedValue(new Error('entitlements down'));
const store = new VirtruAttributeStore(mkClient({ apiCall }));
await expect(store.validateAssignable([{ key: 'clearance', values: ['secret'] }], actor)).rejects.toThrow('entitlements down');
});
it('list propagates when the GetEntitlements call fails', async () => {
const apiCall = jest.fn().mockRejectedValue(new Error('entitlements down'));
const store = new VirtruAttributeStore(mkClient({ apiCall }));
await expect(store.list(actor)).rejects.toThrow('entitlements down');
});
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', verified: true }], 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', verified: true }], 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' });
});
});

View File

@@ -0,0 +1,187 @@
import type { AbacActor } from '@rocket.chat/core-services';
import type { 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<string, { data: Promise<IAbacAttributeDefinition[]>; maxAge: number }>();
private _entitlementsForEntity: (entityId: string) => Promise<IAbacAttributeDefinition[]>;
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<string> {
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<IAbacAttributeDefinition[]> {
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<IGetEntitlementsResponse>('/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<IAbacAttributeDefinition[]> {
const entityId = await this.resolveEntityId(actor);
return this._entitlementsForEntity(entityId);
}
async list(actor: AbacActor | undefined, opts?: ListAttributesOptions): Promise<ListAttributesResult> {
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 })),
offset,
count: slice.length,
total,
};
}
async entitlementsOf(actor: AbacActor): Promise<AttributeEntitlements> {
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<void> {
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<T extends Pick<IRoom, '_id' | 'abacAttributes'>>(
rooms: T[],
actor: AbacActor,
): Promise<Array<T & IRoomAbacRedaction>> {
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<string>();
});
return rooms.map((r) => (permitted.has(r._id) ? r : { ...r, abacAttributes: [], abacAttributesRedacted: true }));
}
async assertCanModifyRoom(room: Pick<IRoom, '_id' | 'abacAttributes'>, actor: AbacActor): Promise<void> {
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<Pick<IRoom, '_id' | 'abacAttributes'>>, actor: AbacActor): Promise<Set<string>> {
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<IGetDecisionBulkResponse>('/authorization.v2.AuthorizationService/GetDecisionBulk', {
decisionRequests: requests,
});
} catch (err) {
throw new PdpUnavailableError();
}
const permitted = new Set<string>();
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;
}
}

View File

@@ -0,0 +1,3 @@
export { LocalAttributeStore } from './LocalAttributeStore';
export { VirtruAttributeStore } from './VirtruAttributeStore';
export type { IAttributeStore, AttributeEntitlements, AttributeStoreDescriptor, AttributeStoreSelectionContext } from './types';

View File

@@ -0,0 +1,38 @@
import type { AbacActor } from '@rocket.chat/core-services';
import type { AbacPdpType, IAbacAttribute, IAbacAttributeDefinition, IRoom, IRoomAbacRedaction } from '@rocket.chat/core-typings';
export type AttributeEntitlements = Map<string, Set<string>>;
export type ListAttributesOptions = { key?: string; values?: string; offset?: number; count?: number };
export type ListAttributesResult = {
attributes: Pick<IAbacAttribute, '_id' | 'key' | 'values'>[];
offset: number;
count: number;
total: number;
};
export interface IAttributeStore {
list(actor: AbacActor | undefined, opts?: ListAttributesOptions): Promise<ListAttributesResult>;
validateAssignable(attrs: IAbacAttributeDefinition[], actor: AbacActor): Promise<void>;
entitlementsOf(actor: AbacActor): Promise<AttributeEntitlements>;
scopeRoomsPage<T extends Pick<IRoom, '_id' | 'abacAttributes'>>(rooms: T[], actor: AbacActor): Promise<Array<T & IRoomAbacRedaction>>;
assertCanModifyRoom(room: Pick<IRoom, '_id' | 'abacAttributes'>, actor: AbacActor): Promise<void>;
onStoreSelected?(): void;
}
export type AttributeStoreSelectionContext = {
abacEnabled: boolean;
pdpType?: AbacPdpType;
licensed: boolean;
};
export type AttributeStoreDescriptor = {
store: IAttributeStore;
isEligible: (ctx: AttributeStoreSelectionContext) => boolean;
};

View File

@@ -0,0 +1,545 @@
import type { ILDAPEntry, IUser, IAbacAttributeDefinition } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import type { Collection, Db } from 'mongodb';
import { AbacService } from './index';
import { acquireSharedInMemoryMongo, SHARED_ABAC_TEST_DB, type SharedMongoConnection } from './test-helpers/mongoMemoryServer';
jest.mock('@rocket.chat/core-services', () => ({
ServiceClass: class {
onSettingChanged = jest.fn();
},
Room: {
removeUserFromRoom: jest.fn(),
},
MeteorError: class extends Error {},
isMeteorError: () => false,
}));
const makeLdapEntry = (overrides: Partial<ILDAPEntry> = {}): ILDAPEntry => ({ ...overrides }) as ILDAPEntry;
type StaticUserDefinition = {
_id: string;
username: string;
};
const staticUserDefinitions: StaticUserDefinition[] = [
{ _id: 'u-no-map-attrs', username: 'user-no-map' },
{ _id: 'u-merge-memberof', username: 'merge-memberof' },
{ _id: 'u-distinct-keys', username: 'distinct-keys' },
{ _id: 'u-merge-array-string', username: 'merge-array-string' },
{ _id: 'u-unset-loss', username: 'unset-loss' },
{ _id: 'u-unset-none', username: 'unset-none' },
{ _id: 'u-loss-values', username: 'loss-values' },
{ _id: 'u-loss-key', username: 'loss-key' },
{ _id: 'u-gain-values', username: 'gain-values' },
{ _id: 'u-order-only', username: 'order-only' },
{ _id: 'u-merge-ldap-dup', username: 'merge-ldap-dup' },
{ _id: 'u-immutability', username: 'immutability' },
{ _id: 'u-loss', username: 'lossy' },
{ _id: 'u-key-loss', username: 'keyloss' },
{ _id: 'u-growth', username: 'growth' },
{ _id: 'u-extra-room-key', username: 'extrakey' },
{ _id: 'u-empty', username: 'empty' },
{ _id: 'u-lose-unrelated', username: 'unrelated' },
];
const staticTestUsers: IUser[] = staticUserDefinitions.map(({ _id, username }) => ({
_id,
username,
roles: [],
type: 'user',
active: true,
createdAt: new Date(0),
_updatedAt: new Date(0),
__rooms: [],
})) as IUser[];
const staticUserIds = staticTestUsers.map(({ _id }) => _id);
const staticUserBaseMap = Object.fromEntries(staticTestUsers.map((user) => [user._id, { ...user }])) as Record<string, IUser>;
const getStaticUser = (_id: string, overrides: Partial<IUser> = {}): IUser => {
const base = staticUserBaseMap[_id];
if (!base) {
throw new Error(`Unknown static user ${_id}`);
}
return {
...base,
...overrides,
_id: base._id,
username: overrides.username ?? base.username,
__rooms: overrides.__rooms ?? base.__rooms ?? [],
};
};
type StaticUserUpdate = Partial<IUser> & { _id: string };
const service = new AbacService();
service.setPdpStrategy('local');
let db: Db;
let sharedMongo: SharedMongoConnection;
let roomsCol: Collection<any>;
let usersCol: Collection<IUser>;
const resetStaticUsers = async () => {
await usersCol.updateMany(
{ _id: { $in: staticUserIds } },
{
$set: {
__rooms: [],
_updatedAt: new Date(),
},
$unset: {
abacAttributes: 1,
},
},
);
};
const configureStaticUsers = async (users: StaticUserUpdate[]) => {
if (!usersCol) {
throw new Error('users collection not initialized');
}
const operations = users.map(({ _id, ...fields }) => {
const update: { $set: Record<string, any>; $unset?: Record<string, 1> } = {
$set: { _updatedAt: new Date() },
};
for (const [key, value] of Object.entries(fields)) {
if (key === 'abacAttributes') {
if (value === undefined) {
update.$unset = { ...(update.$unset || {}), abacAttributes: 1 };
} else {
update.$set.abacAttributes = value;
}
continue;
}
if (key === '__rooms') {
update.$set.__rooms = value ?? [];
continue;
}
update.$set[key] = value;
}
return {
updateOne: {
filter: { _id },
update,
},
};
});
await usersCol.bulkWrite(operations);
};
const insertRooms = async (rooms: { _id: string; abacAttributes?: IAbacAttributeDefinition[] }[]) => {
await roomsCol.insertMany(
rooms.map((room) => ({
_id: room._id,
name: room._id,
t: 'p',
abacAttributes: room.abacAttributes,
})),
);
};
describe('Subject Attributes validation', () => {
beforeAll(async () => {
sharedMongo = await acquireSharedInMemoryMongo(SHARED_ABAC_TEST_DB);
db = sharedMongo.db;
roomsCol = db.collection('rocketchat_room');
usersCol = db.collection('users');
await usersCol.deleteMany({ _id: { $in: staticUserIds } });
await usersCol.insertMany(staticTestUsers);
}, 30_000);
afterAll(async () => {
await usersCol.deleteMany({ _id: { $in: staticUserIds } });
await sharedMongo.release();
});
beforeEach(async () => {
await resetStaticUsers();
await roomsCol.deleteMany({});
});
describe('AbacService.addSubjectAttributes (unit)', () => {
describe('early returns and no-ops', () => {
it('returns early when user has no _id', async () => {
const user = { _id: undefined, username: 'user-no-id' } as unknown as IUser;
await service.addSubjectAttributes(user, makeLdapEntry(), { group: 'dept' });
const found = await Users.findOne({ username: user.username });
expect(found).toBeFalsy();
});
it('does nothing (no update) when map produces no attributes and user had none', async () => {
const userId = 'u-no-map-attrs';
await configureStaticUsers([{ _id: userId, abacAttributes: undefined }]);
const user = getStaticUser(userId);
const ldap = makeLdapEntry({ group: '' });
await service.addSubjectAttributes(user, ldap, { group: 'dept' });
const updated = await Users.findOneById(userId, { projection: { abacAttributes: 1 } });
expect(updated).toBeTruthy();
expect(updated?.abacAttributes ?? undefined).toBeUndefined();
});
});
describe('building and setting attributes', () => {
it('merges multiple LDAP keys mapping to the same ABAC key, deduplicating values', async () => {
const userId = 'u-merge-memberof';
await configureStaticUsers([{ _id: userId }]);
const user = getStaticUser(userId);
const ldap = makeLdapEntry({
memberOf: ['eng', 'sales', 'eng'],
department: ['sales', 'support'],
});
const map = { memberOf: 'dept', department: 'dept' };
await service.addSubjectAttributes(user, ldap, map);
const updated = await Users.findOneById(userId, { projection: { abacAttributes: 1 } });
expect(updated?.abacAttributes).toEqual([{ key: 'dept', values: ['eng', 'sales', 'support'] }]);
});
it('creates distinct ABAC attributes for different mapped keys preserving insertion order', async () => {
const userId = 'u-distinct-keys';
await configureStaticUsers([{ _id: userId }]);
const user = getStaticUser(userId);
const ldap = makeLdapEntry({
groups: ['alpha', 'beta'],
regionCodes: ['emea', 'apac'],
role: 'admin',
});
const map = { groups: 'team', regionCodes: 'region', role: 'role' };
await service.addSubjectAttributes(user, ldap, map);
const updated = await Users.findOneById(userId, { projection: { abacAttributes: 1 } });
expect(updated?.abacAttributes).toEqual([
{ key: 'team', values: ['alpha', 'beta'] },
{ key: 'region', values: ['emea', 'apac'] },
{ key: 'role', values: ['admin'] },
]);
});
it('merges array and string LDAP values into one attribute', async () => {
const userId = 'u-merge-array-string';
await configureStaticUsers([{ _id: userId }]);
const user = getStaticUser(userId);
const ldap = makeLdapEntry({ deptCode: 'eng', deptName: ['engineering', 'eng'] });
const map = { deptCode: 'dept', deptName: 'dept' };
await service.addSubjectAttributes(user, ldap, map);
const updated = await Users.findOneById(userId, { projection: { abacAttributes: 1 } });
expect(updated?.abacAttributes).toEqual([{ key: 'dept', values: ['eng', 'engineering'] }]);
});
});
describe('unsetting attributes when none extracted', () => {
it('unsets abacAttributes when user previously had attributes but now extracts none', async () => {
const userId = 'u-unset-loss';
await configureStaticUsers([{ _id: userId, abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }]);
const user = getStaticUser(userId, { abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] });
const ldap = makeLdapEntry({ other: ['x'] });
const map = { missing: 'dept' };
await service.addSubjectAttributes(user, ldap, map);
const updated = await Users.findOneById(userId, { projection: { abacAttributes: 1 } });
expect(updated?.abacAttributes).toBeUndefined();
});
it('does not unset when user had no prior attributes and extraction yields none', async () => {
const userId = 'u-unset-none';
await configureStaticUsers([{ _id: userId, abacAttributes: [] }]);
const user = getStaticUser(userId, { abacAttributes: [] });
const ldap = makeLdapEntry({});
const map = { missing: 'dept' };
await service.addSubjectAttributes(user, ldap, map);
const updated = await Users.findOneById(userId, { projection: { abacAttributes: 1 } });
expect(updated?.abacAttributes).toEqual([]);
});
});
describe('loss detection triggering hook (attribute changes)', () => {
it('updates attributes reducing values on loss', async () => {
const userId = 'u-loss-values';
await configureStaticUsers([{ _id: userId, abacAttributes: [{ key: 'dept', values: ['eng', 'qa'] }] }]);
const user = getStaticUser(userId, { abacAttributes: [{ key: 'dept', values: ['eng', 'qa'] }] });
const ldap = makeLdapEntry({ memberOf: ['eng'] });
await service.addSubjectAttributes(user, ldap, { memberOf: 'dept' });
const updated = await Users.findOneById(userId, { projection: { abacAttributes: 1 } });
expect(updated?.abacAttributes).toEqual([{ key: 'dept', values: ['eng'] }]);
});
it('updates attributes removing an entire key', async () => {
const userId = 'u-loss-key';
await configureStaticUsers([
{
_id: userId,
abacAttributes: [
{ key: 'dept', values: ['eng'] },
{ key: 'region', values: ['emea'] },
],
},
]);
const user = getStaticUser(userId, {
abacAttributes: [
{ key: 'dept', values: ['eng'] },
{ key: 'region', values: ['emea'] },
],
});
const ldap = makeLdapEntry({ department: ['eng'] });
await service.addSubjectAttributes(user, ldap, { department: 'dept' });
const updated = await Users.findOneById(userId, { projection: { abacAttributes: 1 } });
expect(updated?.abacAttributes).toEqual([{ key: 'dept', values: ['eng'] }]);
});
it('gains new values without triggering loss logic', async () => {
const userId = 'u-gain-values';
await configureStaticUsers([{ _id: userId, abacAttributes: [{ key: 'dept', values: ['eng'] }] }]);
const user = getStaticUser(userId, { abacAttributes: [{ key: 'dept', values: ['eng'] }] });
const ldap = makeLdapEntry({ memberOf: ['eng', 'qa'] });
await service.addSubjectAttributes(user, ldap, { memberOf: 'dept' });
const updated = await Users.findOneById(userId, { projection: { abacAttributes: 1 } });
expect(updated?.abacAttributes).toEqual([{ key: 'dept', values: ['eng', 'qa'] }]);
});
it('keeps attributes unchanged when only ordering differs', async () => {
const userId = 'u-order-only';
await configureStaticUsers([{ _id: userId, abacAttributes: [{ key: 'dept', values: ['eng', 'qa'] }] }]);
const user = getStaticUser(userId, { abacAttributes: [{ key: 'dept', values: ['eng', 'qa'] }] });
const ldap = makeLdapEntry({ memberOf: ['qa', 'eng'] });
await service.addSubjectAttributes(user, ldap, { memberOf: 'dept' });
const updated = await Users.findOneById(userId, { projection: { abacAttributes: 1 } });
expect(updated?.abacAttributes?.[0].key).toBe('dept');
expect(new Set(updated?.abacAttributes?.[0].values)).toEqual(new Set(['eng', 'qa']));
});
it('merges duplicate LDAP mapping keys retaining union of values', async () => {
const userId = 'u-merge-ldap-dup';
await configureStaticUsers([{ _id: userId, abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }]);
const user = getStaticUser(userId, { abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] });
const ldap = makeLdapEntry({ deptA: ['eng', 'sales'], deptB: ['eng'] });
await service.addSubjectAttributes(user, ldap, { deptA: 'dept', deptB: 'dept' });
const updated = await Users.findOneById(userId, { projection: { abacAttributes: 1 } });
expect(updated?.abacAttributes).toEqual([{ key: 'dept', values: ['eng', 'sales'] }]);
});
});
describe('input immutability', () => {
let sharedUser: IUser;
let original: IAbacAttributeDefinition[];
let clone: IAbacAttributeDefinition[];
beforeAll(() => {
original = [{ key: 'dept', values: ['eng', 'sales'] }] as IAbacAttributeDefinition[];
clone = JSON.parse(JSON.stringify(original));
});
beforeEach(async () => {
await configureStaticUsers([{ _id: 'u-immutability', abacAttributes: original }]);
sharedUser = getStaticUser('u-immutability', { abacAttributes: original });
});
it('does not mutate original user.abacAttributes array reference contents', async () => {
const ldap = makeLdapEntry({ memberOf: ['eng', 'sales', 'support'] });
await service.addSubjectAttributes(sharedUser, ldap, { memberOf: 'dept' });
expect(original).toEqual(clone);
});
});
});
describe('AbacService.addSubjectAttributes (room removals)', () => {
const originalCoreServices = jest.requireMock('@rocket.chat/core-services');
originalCoreServices.Room.removeUserFromRoom = async (rid: string, user: IUser) => {
await usersCol.updateOne({ _id: user._id }, { $pull: { __rooms: rid } });
};
it('removes user from rooms whose attributes become non-compliant after losing a value', async () => {
const userId = 'u-loss';
const user = getStaticUser(userId, {
abacAttributes: [{ key: 'dept', values: ['eng', 'qa'] }],
__rooms: ['rKeep', 'rRemove'],
});
await configureStaticUsers([{ _id: userId, abacAttributes: user.abacAttributes, __rooms: user.__rooms }]);
await insertRooms([
{ _id: 'rKeep', abacAttributes: [{ key: 'dept', values: ['eng'] }] },
{ _id: 'rRemove', abacAttributes: [{ key: 'dept', values: ['eng', 'qa'] }] },
]);
const ldap: ILDAPEntry = {
memberOf: ['eng'],
_raw: {},
};
await service.addSubjectAttributes(user, ldap, { memberOf: 'dept' });
const updatedUser = await usersCol.findOne({ _id: userId }, { projection: { abacAttributes: 1, __rooms: 1 } });
expect(updatedUser?.abacAttributes).toEqual([{ key: 'dept', values: ['eng'] }]);
expect(updatedUser?.abacAttributes).not.toEqual(user.abacAttributes);
expect(updatedUser?.__rooms?.sort()).toEqual(['rKeep']);
});
it('removes user from rooms containing attribute keys they no longer possess (key loss)', async () => {
const userId = 'u-key-loss';
const user = getStaticUser(userId, {
abacAttributes: [
{ key: 'dept', values: ['eng'] },
{ key: 'region', values: ['emea'] },
],
__rooms: ['rDeptOnly', 'rRegionOnly', 'rBoth'],
});
await configureStaticUsers([{ _id: userId, abacAttributes: user.abacAttributes, __rooms: user.__rooms }]);
await insertRooms([
{ _id: 'rDeptOnly', abacAttributes: [{ key: 'dept', values: ['eng'] }] },
{ _id: 'rRegionOnly', abacAttributes: [{ key: 'region', values: ['emea'] }] },
{
_id: 'rBoth',
abacAttributes: [
{ key: 'dept', values: ['eng'] },
{ key: 'region', values: ['emea'] },
],
},
]);
const ldap: ILDAPEntry = {
department: ['eng'],
_raw: {},
};
await service.addSubjectAttributes(user, ldap, { department: 'dept' });
const updatedUser = await usersCol.findOne({ _id: userId }, { projection: { abacAttributes: 1, __rooms: 1 } });
expect(updatedUser?.abacAttributes).toEqual([{ key: 'dept', values: ['eng'] }]);
expect(updatedUser?.abacAttributes).not.toEqual(user.abacAttributes);
expect(updatedUser?.__rooms).toEqual(['rDeptOnly']);
});
it('does not remove user from any room when attribute values only grow (gain without loss)', async () => {
const userId = 'u-growth';
const user = getStaticUser(userId, {
abacAttributes: [{ key: 'dept', values: ['eng'] }],
__rooms: ['rGrowthA', 'rGrowthB'],
});
await configureStaticUsers([{ _id: userId, abacAttributes: user.abacAttributes, __rooms: user.__rooms }]);
await insertRooms([
{ _id: 'rGrowthA', abacAttributes: [{ key: 'dept', values: ['eng'] }] },
{ _id: 'rGrowthB', abacAttributes: [{ key: 'dept', values: ['eng', 'qa'] }] },
]);
const ldap: ILDAPEntry = {
memberOf: ['eng', 'qa'],
_raw: {},
};
await service.addSubjectAttributes(user, ldap, { memberOf: 'dept' });
const updatedUser = await usersCol.findOne({ _id: userId }, { projection: { abacAttributes: 1, __rooms: 1 } });
expect(updatedUser?.abacAttributes).toEqual([{ key: 'dept', values: ['eng', 'qa'] }]);
expect(updatedUser?.__rooms?.sort()).toEqual(['rGrowthA', 'rGrowthB']);
});
it('removes user from rooms having attribute keys not present in new attribute set (extra keys in room)', async () => {
const userId = 'u-extra-room-key';
const user = getStaticUser(userId, {
abacAttributes: [
{ key: 'dept', values: ['eng', 'sales'] },
{ key: 'otherKey', values: ['value'] },
],
__rooms: ['rExtraKeyRoom', 'rBaseline'],
});
await configureStaticUsers([{ _id: userId, abacAttributes: user.abacAttributes, __rooms: user.__rooms }]);
await insertRooms([
{
_id: 'rExtraKeyRoom',
abacAttributes: [
{ key: 'dept', values: ['eng', 'sales'] },
{ key: 'project', values: ['X'] },
],
},
{
_id: 'rBaseline',
abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }],
},
]);
const ldap: ILDAPEntry = {
deptCodes: ['eng', 'sales'],
_raw: {},
};
await service.addSubjectAttributes(user, ldap, { deptCodes: 'dept' });
const updatedUser = await usersCol.findOne({ _id: userId }, { projection: { abacAttributes: 1, __rooms: 1 } });
expect(updatedUser?.abacAttributes).toEqual([{ key: 'dept', values: ['eng', 'sales'] }]);
expect(updatedUser?.__rooms?.sort()).toEqual(['rBaseline']);
});
it('unsets attributes and removes user from all ABAC rooms when no LDAP values extracted', async () => {
const userId = 'u-empty';
const user = getStaticUser(userId, {
abacAttributes: [{ key: 'dept', values: ['eng'] }],
__rooms: ['rAny1', 'rAny2'],
});
await configureStaticUsers([{ _id: userId, abacAttributes: user.abacAttributes, __rooms: user.__rooms }]);
await insertRooms([
{ _id: 'rAny1', abacAttributes: [{ key: 'dept', values: ['eng'] }] },
{ _id: 'rAny2', abacAttributes: [{ key: 'dept', values: ['eng', 'qa'] }] },
]);
const ldap: ILDAPEntry = {
unrelated: ['x'],
_raw: {},
};
await service.addSubjectAttributes(user, ldap, { missing: 'dept' });
const updatedUser = await usersCol.findOne({ _id: userId }, { projection: { abacAttributes: 1, __rooms: 1 } });
expect(updatedUser?.abacAttributes).toBeUndefined();
expect(updatedUser?.abacAttributes).not.toEqual(user.abacAttributes);
expect(updatedUser?.__rooms).toEqual([]);
});
it('does not remove user from room when losing attribute not used by room (hook runs but no change)', async () => {
const userId = 'u-lose-unrelated';
const user = getStaticUser(userId, {
abacAttributes: [
{ key: 'dept', values: ['eng'] },
{ key: 'region', values: ['emea'] },
{ key: 'project', values: ['X'] },
],
__rooms: ['rDeptRegion'],
});
await configureStaticUsers([{ _id: userId, abacAttributes: user.abacAttributes, __rooms: user.__rooms }]);
await insertRooms([
{
_id: 'rDeptRegion',
abacAttributes: [
{ key: 'dept', values: ['eng'] },
{ key: 'region', values: ['emea'] },
],
},
]);
const ldap: ILDAPEntry = {
department: ['eng', 'ceo'],
regionCodes: ['emea', 'apac'],
_raw: {},
};
await service.addSubjectAttributes(user, ldap, { department: 'dept', regionCodes: 'region', projectCodes: 'project' });
const updatedUser = await usersCol.findOne({ _id: userId }, { projection: { abacAttributes: 1, __rooms: 1 } });
expect(updatedUser?.abacAttributes).toEqual([
{ key: 'dept', values: ['eng', 'ceo'] },
{ key: 'region', values: ['emea', 'apac'] },
]);
expect(updatedUser?.abacAttributes).not.toEqual(user.abacAttributes);
expect(updatedUser?.__rooms).toEqual(['rDeptRegion']);
});
});
});

View File

@@ -0,0 +1,94 @@
import { registerModel, UsersRaw, RoomsRaw, AbacAttributesRaw, ServerEventsRaw, SubscriptionsRaw } from '@rocket.chat/models';
import type { Db } from 'mongodb';
import { MongoClient } from 'mongodb';
import { MongoMemoryServer } from 'mongodb-memory-server';
export const SHARED_ABAC_TEST_DB = 'abac_test';
type SharedState = {
mongo: MongoMemoryServer;
client: MongoClient;
refCount: number;
};
let sharedState: SharedState | null = null;
let initialization: Promise<SharedState> | null = null;
const ensureState = async (): Promise<SharedState> => {
if (sharedState) {
return sharedState;
}
if (!initialization) {
initialization = (async () => {
const mongo = await MongoMemoryServer.create();
const client = await MongoClient.connect(mongo.getUri(), {});
return {
mongo,
client,
refCount: 0,
};
})();
}
sharedState = await initialization;
initialization = null;
return sharedState;
};
const dropDatabase = async (db: Db) => {
try {
await db.dropDatabase();
} catch (err) {
if (!(err instanceof Error) || !/ns not found/i.test(err.message)) {
throw err;
}
}
};
export type SharedMongoConnection = {
mongo: MongoMemoryServer;
client: MongoClient;
db: Db;
cleanupDatabase: () => Promise<void>;
release: () => Promise<void>;
};
const registerAbacTestModels = (db: Db) => {
registerModel('IUsersModel', () => new UsersRaw(db));
registerModel('IRoomsModel', () => new RoomsRaw(db));
registerModel('IAbacAttributesModel', () => new AbacAttributesRaw(db));
registerModel('IServerEventsModel', () => new ServerEventsRaw(db));
registerModel('ISubscriptionsModel', () => new SubscriptionsRaw(db));
};
export const acquireSharedInMemoryMongo = async (dbName: string): Promise<SharedMongoConnection> => {
const state = await ensureState();
state.refCount += 1;
const connectionDb = state.client.db(dbName);
let released = false;
registerAbacTestModels(connectionDb);
return {
mongo: state.mongo,
client: state.client,
db: connectionDb,
cleanupDatabase: async () => dropDatabase(connectionDb),
release: async () => {
if (released || !sharedState) {
return;
}
released = true;
sharedState.refCount -= 1;
if (sharedState.refCount === 0) {
const { client, mongo } = sharedState;
sharedState = null;
await client.close();
await mongo.stop();
}
},
};
};

View File

@@ -0,0 +1,592 @@
import type { IAbacAttributeDefinition, IRoom, IUser } from '@rocket.chat/core-typings';
import { Subscriptions } from '@rocket.chat/models';
import type { Collection, Db } from 'mongodb';
import { Audit } from './audit';
import { AbacService } from './index';
import { logger } from './logger';
import { acquireSharedInMemoryMongo, SHARED_ABAC_TEST_DB, type SharedMongoConnection } from './test-helpers/mongoMemoryServer';
jest.mock('@rocket.chat/core-services', () => ({
ServiceClass: class {
onSettingChanged = jest.fn();
},
Room: {
// Mimic the DB side-effects of removing a user from a room (no apps/system messages)
removeUserFromRoom: async (roomId: string, user: any) => {
await Subscriptions.removeByRoomIdAndUserId(roomId, user._id);
},
},
api: {
broadcast: jest.fn(),
},
License: {
hasModule: async () => true,
},
Authorization: {
hasPermission: async () => false,
},
MeteorError: class extends Error {},
isMeteorError: () => false,
}));
describe('AbacService integration (onRoomAttributesChanged)', () => {
let sharedMongo: SharedMongoConnection;
let db: Db;
const service = new AbacService();
service.setPdpStrategy('local');
let roomsCol: Collection<IRoom>;
let usersCol: Collection<IUser>;
const fakeActor = { _id: 'test-user', username: 'testuser', type: 'user' };
const insertDefinitions = async (defs: { key: string; values: string[] }[]) => {
await Promise.all(
defs.map((def) =>
service.addAbacAttribute({ key: def.key, values: def.values }, fakeActor).catch((e: any) => {
if (e instanceof Error && e.message === 'error-duplicate-attribute-key') {
return;
}
throw e;
}),
),
);
};
const insertRoom = async (abacAttributes: IAbacAttributeDefinition[] = []) => {
const rid = `r-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
await roomsCol.insertOne({
_id: rid,
t: 'p',
name: `Test Room ${rid}`,
abacAttributes,
} as any);
return rid;
};
type TestUserSeed = {
_id: string;
abacAttributes?: IAbacAttributeDefinition[];
extraRooms?: string[];
};
const insertUsers = async (users: TestUserSeed[]) => {
await usersCol.insertMany(
users.map((u) => {
const doc: Partial<IUser> & {
_id: string;
username: string;
type: IUser['type'];
roles: IUser['roles'];
active: boolean;
createdAt: Date;
_updatedAt: Date;
__rooms: string[];
} = {
_id: u._id,
username: u._id,
type: 'user',
roles: [],
active: true,
createdAt: new Date(),
_updatedAt: new Date(),
__rooms: u.extraRooms || [],
};
if (u.abacAttributes !== undefined) {
doc.abacAttributes = u.abacAttributes;
}
return doc as IUser;
}),
);
};
const staticUserIds = [
'u1_newkey',
'u2_newkey',
'u3_newkey',
'u4_newkey',
'u5_newkey',
'u1_dupvals',
'u2_dupvals',
'u1_newval',
'u2_newval',
'u3_newval',
'u1_rmval',
'u2_rmval',
'u1_multi',
'u2_multi',
'u3_multi',
'u4_multi',
'u5_multi',
'u1_idem',
'u2_idem',
'u1_superset',
'u2_superset',
'u1_misskey',
'u2_misskey',
'u3_misskey',
];
const staticTestUsers: TestUserSeed[] = staticUserIds.map((_id) => ({ _id }));
const resetStaticUsers = async () => {
await usersCol.updateMany(
{ _id: { $in: staticUserIds } },
{
$set: {
__rooms: [],
_updatedAt: new Date(),
},
$unset: {
abacAttributes: 1,
},
},
);
};
const configureStaticUsers = async (users: TestUserSeed[]) => {
const operations = users.map((user) => {
const setPayload: Partial<IUser> = {
__rooms: user.extraRooms ?? [],
_updatedAt: new Date(),
};
if (user.abacAttributes !== undefined) {
setPayload.abacAttributes = user.abacAttributes;
}
const update: {
$set: Partial<IUser>;
$unset?: Record<string, 1>;
} = {
$set: setPayload,
};
if (user.abacAttributes === undefined) {
update.$unset = { abacAttributes: 1 };
}
return {
updateOne: {
filter: { _id: user._id },
update,
},
};
});
if (!operations.length) {
return;
}
await usersCol.bulkWrite(operations);
};
// It's utterly incredible i have to do this so the tests are "fast" because mongo is not warm
// I could have increased the timeout for the first test too but...
const dbWarmup = async () => {
const uniqueSuffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const warmupAttributeKey = `warmup_seed_${uniqueSuffix}`;
const warmupUserId = `warmup-abac-user-${uniqueSuffix}`;
const warmupRid = await insertRoom([]);
const subscriptionCol = db.collection<any>('rocketchat_subscription');
const seedSubscriptionId = `warmup-${uniqueSuffix}`;
await subscriptionCol.insertOne({
_id: seedSubscriptionId,
rid: `warmup-room-${uniqueSuffix}`,
u: { _id: `warmup-user-${uniqueSuffix}` },
} as any);
await insertDefinitions([{ key: warmupAttributeKey, values: ['a'] }]);
await insertUsers([{ _id: warmupUserId, extraRooms: [warmupRid] }]);
await subscriptionCol.insertOne({
_id: `warmup-sub-${uniqueSuffix}`,
rid: warmupRid,
u: { _id: warmupUserId },
} as any);
try {
await service.setRoomAbacAttributes(warmupRid, { [warmupAttributeKey]: ['a'] }, fakeActor);
} finally {
await roomsCol.deleteOne({ _id: warmupRid });
await usersCol.deleteOne({ _id: warmupUserId });
await subscriptionCol.deleteMany({
_id: { $in: [seedSubscriptionId, `warmup-sub-${uniqueSuffix}`] },
});
await subscriptionCol.deleteMany({ rid: warmupRid });
await db.collection<any>('rocketchat_abac_attributes').deleteOne({ key: warmupAttributeKey });
}
};
let debugSpy: jest.SpyInstance;
let auditSpy: jest.SpyInstance;
beforeAll(async () => {
sharedMongo = await acquireSharedInMemoryMongo(SHARED_ABAC_TEST_DB);
db = sharedMongo.db;
debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => undefined);
auditSpy = jest.spyOn(Audit, 'actionPerformed').mockResolvedValue();
roomsCol = db.collection<IRoom>('rocketchat_room');
usersCol = db.collection<IUser>('users');
await usersCol.deleteMany({ _id: { $in: staticUserIds } });
await insertUsers(staticTestUsers);
await dbWarmup();
}, 30_000);
afterAll(async () => {
await usersCol.deleteMany({ _id: { $in: staticUserIds } });
await sharedMongo.release();
});
beforeEach(async () => {
debugSpy.mockClear();
auditSpy.mockClear();
await resetStaticUsers();
});
describe('setRoomAbacAttributes - new key addition', () => {
let rid1: string;
let rid2: string;
beforeEach(async () => {
rid1 = await insertRoom([]);
rid2 = await insertRoom([]);
await insertDefinitions([
{ key: 'dept', values: ['eng', 'sales', 'hr'] },
{ key: 'dep2t', values: ['eng', 'sales'] },
]);
await configureStaticUsers([
{ _id: 'u1_newkey', extraRooms: [rid1], abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }, // compliant
{ _id: 'u2_newkey', extraRooms: [rid1], abacAttributes: [{ key: 'dept', values: ['eng'] }] }, // missing sales
{ _id: 'u3_newkey', extraRooms: [rid1], abacAttributes: [{ key: 'location', values: ['emea'] }] }, // missing dept key
{ _id: 'u4_newkey', extraRooms: [rid1], abacAttributes: [{ key: 'dept', values: ['eng', 'sales', 'hr'] }] }, // superset
{ _id: 'u5_newkey', abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }, // not in room
{ _id: 'u1_dupvals', extraRooms: [rid2], abacAttributes: [{ key: 'dep2t', values: ['eng', 'sales'] }] },
{ _id: 'u2_dupvals', extraRooms: [rid2], abacAttributes: [{ key: 'dep2t', values: ['eng'] }] }, // non compliant (missing sales)
]);
});
afterEach(async () => {
await roomsCol.deleteMany({ _id: { $in: [rid1, rid2] } });
});
it('logs users that do not satisfy newly added attribute key or its values and actually removes them', async () => {
const changeSpy = jest.spyOn<any, any>(service as any, 'onRoomAttributesChanged');
await service.setRoomAbacAttributes(rid1, { dept: ['eng', 'sales'] }, fakeActor);
expect(changeSpy).toHaveBeenCalledTimes(1);
expect(changeSpy.mock.calls[0][0]).toMatchObject({ _id: rid1 });
expect(Array.isArray((changeSpy as any).mock.calls[0][0].abacAttributes)).toBe(true);
expect(auditSpy).toHaveBeenCalledTimes(2);
const auditedUsers = auditSpy.mock.calls.map((call) => call[0]._id).sort();
const auditedRooms = new Set(auditSpy.mock.calls.map((call) => call[1]._id));
const auditedActions = new Set(auditSpy.mock.calls.map((call) => call[2]));
expect(auditedUsers).toEqual(['u2_newkey', 'u3_newkey']);
expect(auditedRooms).toEqual(new Set([rid1]));
expect(auditedActions).toEqual(new Set(['room-attributes-change']));
});
it('handles duplicate values in room attributes equivalently to unique set (logs non compliant and removes them)', async () => {
await service.setRoomAbacAttributes(rid2, { dep2t: ['eng', 'eng', 'sales'] }, fakeActor);
expect(auditSpy).toHaveBeenCalledTimes(1);
expect(auditSpy.mock.calls[0][0]).toMatchObject({ _id: 'u2_dupvals', username: 'u2_dupvals' });
expect(auditSpy.mock.calls[0][1]).toMatchObject({ _id: rid2 });
expect(auditSpy.mock.calls[0][2]).toBe('room-attributes-change');
const u1 = await usersCol.findOne({ _id: 'u1_dupvals' }, { projection: { __rooms: 1 } });
const u2 = await usersCol.findOne({ _id: 'u2_dupvals' }, { projection: { __rooms: 1 } });
expect(u1?.__rooms || []).toContain(rid2);
expect(u2?.__rooms || []).not.toContain(rid2);
});
});
describe('updateRoomAbacAttributeValues - new value addition', () => {
describe('when adding new values', () => {
let rid: string;
beforeEach(async () => {
rid = await insertRoom([{ key: 'dept', values: ['eng'] }]);
await insertDefinitions([{ key: 'dept', values: ['eng', 'sales'] }]);
await configureStaticUsers([
{ _id: 'u1_newval', extraRooms: [rid], abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }, // already superset
{ _id: 'u2_newval', extraRooms: [rid], abacAttributes: [{ key: 'dept', values: ['eng'] }] }, // missing new value
{ _id: 'u3_newval', extraRooms: [rid], abacAttributes: [{ key: 'dept', values: ['eng', 'sales', 'hr'] }] }, // superset
]);
});
afterEach(async () => {
await roomsCol.deleteOne({ _id: rid });
});
it('logs users missing newly added value while retaining compliant ones and removes the missing ones', async () => {
await service.updateRoomAbacAttributeValues(rid, 'dept', ['eng', 'sales'], fakeActor);
expect(auditSpy).toHaveBeenCalledTimes(1);
expect(auditSpy.mock.calls[0][0]).toMatchObject({ _id: 'u2_newval', username: 'u2_newval' });
expect(auditSpy.mock.calls[0][1]).toMatchObject({ _id: rid });
expect(auditSpy.mock.calls[0][2]).toBe('room-attributes-change');
});
});
describe('when only removing values', () => {
let rid: string;
beforeEach(async () => {
rid = await insertRoom([{ key: 'dept', values: ['eng', 'sales'] }]);
await insertDefinitions([{ key: 'dept', values: ['eng', 'sales'] }]);
await configureStaticUsers([
{ _id: 'u1_rmval', extraRooms: [rid], abacAttributes: [{ key: 'dept', values: ['eng'] }] },
{ _id: 'u2_rmval', extraRooms: [rid], abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] },
]);
});
afterEach(async () => {
await roomsCol.deleteOne({ _id: rid });
});
it('produces no evaluation log when only removing values from existing attribute', async () => {
await service.updateRoomAbacAttributeValues(rid, 'dept', ['eng'], fakeActor); // removal only
expect(auditSpy).not.toHaveBeenCalled();
// nobody removed because removal only does not trigger reevaluation
const u1 = await usersCol.findOne({ _id: 'u1_rmval' }, { projection: { __rooms: 1 } });
const u2 = await usersCol.findOne({ _id: 'u2_rmval' }, { projection: { __rooms: 1 } });
expect(u1?.__rooms || []).toContain(rid);
expect(u2?.__rooms || []).toContain(rid);
});
});
});
describe('setRoomAbacAttributes - multi-attribute addition', () => {
let rid: string;
beforeEach(async () => {
rid = await insertRoom([{ key: 'dept', values: ['eng'] }]);
await insertDefinitions([
{ key: 'dept', values: ['eng', 'sales', 'hr'] },
{ key: 'region', values: ['emea', 'apac'] },
]);
await configureStaticUsers([
{
_id: 'u1_multi',
extraRooms: [rid],
abacAttributes: [
{ key: 'dept', values: ['eng', 'sales'] },
{ key: 'region', values: ['emea'] },
],
}, // compliant after expansion
{
_id: 'u2_multi',
extraRooms: [rid],
abacAttributes: [{ key: 'dept', values: ['eng'] }], // missing region
},
{
_id: 'u3_multi',
extraRooms: [rid],
abacAttributes: [{ key: 'region', values: ['emea'] }], // missing dept key
},
{
_id: 'u4_multi',
extraRooms: [rid],
abacAttributes: [
{ key: 'dept', values: ['eng', 'sales', 'hr'] },
{ key: 'region', values: ['emea', 'apac'] },
],
}, // superset across both
{
_id: 'u5_multi',
extraRooms: [rid],
abacAttributes: [
{ key: 'dept', values: ['eng', 'sales'] },
{ key: 'region', values: ['apac'] },
],
},
]);
});
afterEach(async () => {
await roomsCol.deleteOne({ _id: rid });
});
it('enforces all attributes (AND semantics) removing users failing any', async () => {
await service.setRoomAbacAttributes(
rid,
{
dept: ['eng', 'sales'],
region: ['emea'],
},
fakeActor,
);
expect(auditSpy).toHaveBeenCalledTimes(3);
const auditedUsers = auditSpy.mock.calls.map((call) => call[0]._id).sort();
expect(auditedUsers).toEqual(['u2_multi', 'u3_multi', 'u5_multi']);
const auditedRooms = new Set(auditSpy.mock.calls.map((call) => call[1]._id));
expect(auditedRooms).toEqual(new Set([rid]));
const auditedActions = new Set(auditSpy.mock.calls.map((call) => call[2]));
expect(auditedActions).toEqual(new Set(['room-attributes-change']));
});
});
describe('Idempotency & no-op behavior', () => {
let rid: string;
beforeEach(async () => {
rid = await insertRoom([]);
await insertDefinitions([{ key: 'dept', values: ['eng', 'sales'] }]);
await configureStaticUsers([
{ _id: 'u1_idem', extraRooms: [rid], abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] },
{ _id: 'u2_idem', extraRooms: [rid], abacAttributes: [{ key: 'dept', values: ['eng'] }] }, // will be removed on first pass
]);
});
afterEach(async () => {
await roomsCol.deleteOne({ _id: rid });
});
it('does not remove anyone when calling with identical attribute set twice', async () => {
await service.setRoomAbacAttributes(rid, { dept: ['eng', 'sales'] }, fakeActor);
expect(auditSpy).toHaveBeenCalledTimes(1);
expect(auditSpy.mock.calls[0][0]).toMatchObject({ _id: 'u2_idem', username: 'u2_idem' });
expect(auditSpy.mock.calls[0][1]).toMatchObject({ _id: rid });
expect(auditSpy.mock.calls[0][2]).toBe('room-attributes-change');
let u1 = await usersCol.findOne({ _id: 'u1_idem' }, { projection: { __rooms: 1 } });
let u2 = await usersCol.findOne({ _id: 'u2_idem' }, { projection: { __rooms: 1 } });
expect(u1?.__rooms || []).toContain(rid);
expect(u2?.__rooms || []).not.toContain(rid);
// Reset mock counts for clarity
debugSpy.mockClear();
auditSpy.mockClear();
await service.setRoomAbacAttributes(rid, { dept: ['eng', 'sales'] }, fakeActor);
expect(auditSpy).not.toHaveBeenCalled();
u1 = await usersCol.findOne({ _id: 'u1_idem' }, { projection: { __rooms: 1 } });
u2 = await usersCol.findOne({ _id: 'u2_idem' }, { projection: { __rooms: 1 } });
expect(u1?.__rooms || []).toContain(rid);
expect(u2?.__rooms || []).not.toContain(rid);
});
});
describe('Superset and missing attribute edge cases', () => {
let ridSuperset: string;
let ridMissingKey: string;
beforeEach(async () => {
ridSuperset = await insertRoom([]);
await insertDefinitions([{ key: 'dept', values: ['eng', 'sales', 'hr'] }]);
await configureStaticUsers([
{
_id: 'u1_superset',
extraRooms: [ridSuperset],
abacAttributes: [{ key: 'dept', values: ['eng', 'sales', 'hr'] }],
},
{ _id: 'u2_superset', extraRooms: [ridSuperset], abacAttributes: [{ key: 'dept', values: ['eng', 'hr'] }] }, // missing sales
]);
ridMissingKey = await insertRoom([]);
await insertDefinitions([{ key: 'region', values: ['emea', 'apac'] }]);
await configureStaticUsers([
{ _id: 'u1_misskey', extraRooms: [ridMissingKey], abacAttributes: [{ key: 'region', values: ['emea'] }] },
{ _id: 'u2_misskey', extraRooms: [ridMissingKey], abacAttributes: [{ key: 'dept', values: ['eng'] }] }, // missing region
{ _id: 'u3_misskey', extraRooms: [ridMissingKey] }, // no abacAttributes field
]);
});
afterEach(async () => {
await roomsCol.deleteMany({ _id: { $in: [ridSuperset, ridMissingKey] } });
});
it('keeps user with superset values and removes user missing one required value', async () => {
await service.setRoomAbacAttributes(ridSuperset, { dept: ['eng', 'sales', 'hr'] }, fakeActor);
expect(auditSpy).toHaveBeenCalledTimes(1);
expect(auditSpy.mock.calls[0][0]).toMatchObject({ _id: 'u2_superset', username: 'u2_superset' });
expect(auditSpy.mock.calls[0][1]).toMatchObject({ _id: ridSuperset });
expect(auditSpy.mock.calls[0][2]).toBe('room-attributes-change');
const u1 = await usersCol.findOne({ _id: 'u1_superset' }, { projection: { __rooms: 1 } });
const u2 = await usersCol.findOne({ _id: 'u2_superset' }, { projection: { __rooms: 1 } });
expect(u1?.__rooms || []).toContain(ridSuperset);
expect(u2?.__rooms || []).not.toContain(ridSuperset);
});
it('removes user missing attribute key entirely', async () => {
await service.setRoomAbacAttributes(ridMissingKey, { region: ['emea'] }, fakeActor);
expect(auditSpy).toHaveBeenCalledTimes(2);
const auditedUsers = auditSpy.mock.calls.map((call) => call[0]._id).sort();
expect(auditedUsers).toEqual(['u2_misskey', 'u3_misskey']);
const auditedRooms = new Set(auditSpy.mock.calls.map((call) => call[1]._id));
expect(auditedRooms).toEqual(new Set([ridMissingKey]));
const auditedActions = new Set(auditSpy.mock.calls.map((call) => call[2]));
expect(auditedActions).toEqual(new Set(['room-attributes-change']));
});
});
describe('Large member set performance sanity (lightweight)', () => {
let rid: string;
let bulkIds: string[];
beforeEach(async () => {
rid = await insertRoom([]);
await insertDefinitions([{ key: 'dept', values: ['eng', 'sales'] }]);
const bulk: Parameters<typeof insertUsers>[0] = [];
for (let i = 0; i < 300; i++) {
// Half compliant with both values, half only 'eng'
const values = i % 2 === 0 ? ['eng', 'sales'] : ['eng'];
bulk.push({
_id: `u${i}`,
extraRooms: [rid],
abacAttributes: [{ key: 'dept', values }],
});
}
bulkIds = bulk.map((u) => u._id);
await insertUsers(bulk);
});
afterEach(async () => {
await usersCol.deleteMany({
_id: { $in: bulkIds },
});
await roomsCol.deleteOne({ _id: rid });
});
it('removes only expected fraction in a larger population', async () => {
await service.setRoomAbacAttributes(rid, { dept: ['eng', 'sales'] }, fakeActor);
expect(auditSpy).toHaveBeenCalledTimes(150);
const removed = auditSpy.mock.calls.map((call) => call[0]._id);
expect(removed.length).toBe(150);
expect(removed).toContain('u1');
expect(removed).toContain('u299');
expect(removed).not.toContain('u0');
expect(removed).not.toContain('u298');
const auditedRooms = new Set(auditSpy.mock.calls.map((call) => call[1]._id));
expect(auditedRooms).toEqual(new Set([rid]));
const auditedActions = new Set(auditSpy.mock.calls.map((call) => call[2]));
expect(auditedActions).toEqual(new Set(['room-attributes-change']));
});
});
});

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["./dist", "./src/**/*.spec.ts"]
}

View File

@@ -0,0 +1,10 @@
{
"extends": "@rocket.chat/tsconfig/server.json",
"compilerOptions": {
"declaration": true,
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["./src/**/*"],
"exclude": ["./dist"]
}

View File

@@ -0,0 +1 @@
ENTERPRISE_LICENSE_RC1=

View File

@@ -0,0 +1,2 @@
.nyc_output
logs/

View File

@@ -0,0 +1,992 @@
# @rocket.chat/federation-matrix
## 0.2.1
### Patch Changes
- ([#41717](https://github.com/RocketChat/Rocket.Chat/pull/41717)) Fixes federation endpoints rejecting valid requests, which broke:
- room history backfill
- image thumbnails
- room message pagination
- accepting an invite from another homeserver
- <details><summary>Updated dependencies [097884fb750bb68396a299230e2f20219dfe0e5a, 4947601bbf042cd1b2385f8f5dda438e608faea7, 0869925e52ca61a440a01a6646935b89af8c7aae, 18f5eb45d098ca64d403fdae4704211000efba29, b89a8d411ef65f6931a5fd1cd057740bc00cd9ba, 6dc66fbe33b7dae6e386e51dadb56b0a70994e80, 20528861308cee6561444fd970c9a080013a7ecf, 5deefe2766132f13118f38dc42cf1c9895208961, 8984df841e1ecc46198e073982adabc1f069c352, 7f9592dc92567993f41d0e3510ecc9c00637a4cc, a9f3e6cd3f31eac17bec626287fcf815d802e9b9, 742009a09148e33d141f50691cdec0f7e9818535]:</summary>
- @rocket.chat/rest-typings@8.8.0
- @rocket.chat/core-typings@8.8.0
- @rocket.chat/models@2.5.0
- @rocket.chat/core-services@0.16.0
- @rocket.chat/network-broker@0.2.39
</details>
## 0.2.1-rc.0
### Patch Changes
- ([#41717](https://github.com/RocketChat/Rocket.Chat/pull/41717)) Fixes federation endpoints rejecting valid requests, which broke:
- room history backfill
- image thumbnails
- room message pagination
- accepting an invite from another homeserver
- <details><summary>Updated dependencies [097884fb750bb68396a299230e2f20219dfe0e5a, 4947601bbf042cd1b2385f8f5dda438e608faea7, 0869925e52ca61a440a01a6646935b89af8c7aae, 18f5eb45d098ca64d403fdae4704211000efba29, b89a8d411ef65f6931a5fd1cd057740bc00cd9ba, 6dc66fbe33b7dae6e386e51dadb56b0a70994e80, 20528861308cee6561444fd970c9a080013a7ecf, 5deefe2766132f13118f38dc42cf1c9895208961, 8984df841e1ecc46198e073982adabc1f069c352, 7f9592dc92567993f41d0e3510ecc9c00637a4cc, a9f3e6cd3f31eac17bec626287fcf815d802e9b9, 742009a09148e33d141f50691cdec0f7e9818535]:</summary>
- @rocket.chat/rest-typings@8.8.0-rc.0
- @rocket.chat/core-typings@8.8.0-rc.0
- @rocket.chat/models@2.5.0-rc.0
- @rocket.chat/core-services@0.16.0-rc.0
- @rocket.chat/license@1.2.0
- @rocket.chat/emitter@0.33.0
- @rocket.chat/http-router@7.9.23
- @rocket.chat/network-broker@0.2.39-rc.0
</details>
## 0.2.0
### Minor Changes
- ([#39324](https://github.com/RocketChat/Rocket.Chat/pull/39324)) Adds support for running Rocket.Chat in FIPS mode. The monolith and all microservices (ddp-streamer, account-service, authorization-service, presence-service, queue-worker, omnichannel-transcript) can now enforce FIPS-compliant cryptography via Node.js/OpenSSL FIPS, with dedicated FIPS Docker images. Running in FIPS mode requires a license including the new `fips` module, and FIPS status is now reported in server logs and statistics.
### Patch Changes
- <details><summary>Updated dependencies [c7aff48a40a9a78924cbf27fd38930c536ee11e5, 5f92f9a27dca70d506d919351612bd32dc04241a, 13b4a7b2dc203959b77b3b0c5f154d3e34fe2058, 4b34bd62f2ac8d51efd2f48caea7092e87f30ce7, 1bf84cbe288df03fc622fbddbc0e434bda291c2f, 4b57346a59b5c9433c25845c886be11af1bf71d4, 8d8cd01d0a4e6872ed543320c966efd52140e884, 6ebabce27a153d36ea8426f3257ffff880e820bc, ffe1b646226eeeda5a4d4697c831e568ec1eec64, 3cd7db677a72521439b564dca7a4ca6d6c3a1c07, 719e3db9734708e812ceb33483ffaa2e064b4d59, b2b5edf5b37be3ee070290553f6aec42c39c98f6, 74d6cacb77cc2b029dfd1a12890cfcae7fd540cb, 73c3aec49bacdfb42e67b6cbf90dfffbb65e0658, 73c3aec49bacdfb42e67b6cbf90dfffbb65e0658, 73c3aec49bacdfb42e67b6cbf90dfffbb65e0658, 73c3aec49bacdfb42e67b6cbf90dfffbb65e0658, 615ae2bf74bba0402e0151d9c0b8e4f8dd04cb17, 1cc7bbdef9330899a8207d3d55130f48321d68f1]:</summary>
- @rocket.chat/core-typings@8.7.0
- @rocket.chat/models@2.4.0
- @rocket.chat/rest-typings@8.7.0
- @rocket.chat/core-services@0.15.0
- @rocket.chat/license@1.2.0
- @rocket.chat/network-broker@0.2.38
</details>
## 0.2.0-rc.0
### Minor Changes
- ([#39324](https://github.com/RocketChat/Rocket.Chat/pull/39324)) Adds support for running Rocket.Chat in FIPS mode. The monolith and all microservices (ddp-streamer, account-service, authorization-service, presence-service, queue-worker, omnichannel-transcript) can now enforce FIPS-compliant cryptography via Node.js/OpenSSL FIPS, with dedicated FIPS Docker images. Running in FIPS mode requires a license including the new `fips` module, and FIPS status is now reported in server logs and statistics.
### Patch Changes
- <details><summary>Updated dependencies [c7aff48a40a9a78924cbf27fd38930c536ee11e5, 5f92f9a27dca70d506d919351612bd32dc04241a, 13b4a7b2dc203959b77b3b0c5f154d3e34fe2058, 4b34bd62f2ac8d51efd2f48caea7092e87f30ce7, 1bf84cbe288df03fc622fbddbc0e434bda291c2f, 4b57346a59b5c9433c25845c886be11af1bf71d4, 8d8cd01d0a4e6872ed543320c966efd52140e884, 6ebabce27a153d36ea8426f3257ffff880e820bc, ffe1b646226eeeda5a4d4697c831e568ec1eec64, 3cd7db677a72521439b564dca7a4ca6d6c3a1c07, 719e3db9734708e812ceb33483ffaa2e064b4d59, b2b5edf5b37be3ee070290553f6aec42c39c98f6, 74d6cacb77cc2b029dfd1a12890cfcae7fd540cb, 73c3aec49bacdfb42e67b6cbf90dfffbb65e0658, 73c3aec49bacdfb42e67b6cbf90dfffbb65e0658, 73c3aec49bacdfb42e67b6cbf90dfffbb65e0658, 73c3aec49bacdfb42e67b6cbf90dfffbb65e0658, 615ae2bf74bba0402e0151d9c0b8e4f8dd04cb17, 1cc7bbdef9330899a8207d3d55130f48321d68f1]:</summary>
- @rocket.chat/core-typings@8.7.0-rc.0
- @rocket.chat/models@2.4.0-rc.0
- @rocket.chat/rest-typings@8.7.0-rc.0
- @rocket.chat/core-services@0.15.0-rc.0
- @rocket.chat/license@1.2.0-rc.0
- @rocket.chat/network-broker@0.2.38-rc.0
</details>
## 0.1.6
### Patch Changes
- ([#41046](https://github.com/RocketChat/Rocket.Chat/pull/41046)) Fixes an issue where editing or deleting a message in a federated room caused subsequent messages to stop syncing between servers
Note: this prevents the issue from happening, but does not restore rooms that are already affected. Recovering those requires a separate, one-time repair.
- ([#40839](https://github.com/RocketChat/Rocket.Chat/pull/40839)) Fixes an issue where `description` was incorrectly being used as alternative text for image attachments
- <details><summary>Updated dependencies [73e12e1707baea845395e0582892f65456598672, a7279cebc73edfa4b991eb593730c08e8f5e9001, 7380c44c751eff9ee624d80bf26370411ffed78b, f4f361234f00bd44efe348df4355e9d3cf80efe0, 9a36221f1fbf5ca417325204637c9f32fe760443, f57901d91feaccedd00dee65b78775b20235825b, f57901d91feaccedd00dee65b78775b20235825b, f57901d91feaccedd00dee65b78775b20235825b, 9a36221f1fbf5ca417325204637c9f32fe760443, 9a36221f1fbf5ca417325204637c9f32fe760443, fa685d0ddfdf1167705a58b5d846a993144e3734, 6bd9182ae1d914a55e70866db43e8d2038f7be28, 6fa5378a940cbc809800b3c7d7c0639810bb0ab8, f63b965f82b0ddc590c633706f7c31c8c5251b53]:</summary>
- @rocket.chat/models@2.3.1
- @rocket.chat/core-services@0.14.2
- @rocket.chat/core-typings@8.6.0
- @rocket.chat/rest-typings@8.6.0
- @rocket.chat/network-broker@0.2.37
</details>
## 0.1.6-rc.1
### Patch Changes
- ([#41046](https://github.com/RocketChat/Rocket.Chat/pull/41046)) Fixes an issue where editing or deleting a message in a federated room caused subsequent messages to stop syncing between servers
Note: this prevents the issue from happening, but does not restore rooms that are already affected. Recovering those requires a separate, one-time repair.
- <details><summary>Updated dependencies [6fa5378a940cbc809800b3c7d7c0639810bb0ab8]:</summary>
- @rocket.chat/core-typings@8.6.0-rc.1
- @rocket.chat/rest-typings@8.6.0-rc.1
</details>
## 0.1.6-rc.0
### Patch Changes
- ([#40839](https://github.com/RocketChat/Rocket.Chat/pull/40839)) Fixes an issue where `description` was incorrectly being used as alternative text for image attachments
- <details><summary>Updated dependencies [73e12e1707baea845395e0582892f65456598672, a7279cebc73edfa4b991eb593730c08e8f5e9001, 7380c44c751eff9ee624d80bf26370411ffed78b, f4f361234f00bd44efe348df4355e9d3cf80efe0, 9a36221f1fbf5ca417325204637c9f32fe760443, f57901d91feaccedd00dee65b78775b20235825b, f57901d91feaccedd00dee65b78775b20235825b, f57901d91feaccedd00dee65b78775b20235825b, 9a36221f1fbf5ca417325204637c9f32fe760443, 9a36221f1fbf5ca417325204637c9f32fe760443, fa685d0ddfdf1167705a58b5d846a993144e3734, 6bd9182ae1d914a55e70866db43e8d2038f7be28, f63b965f82b0ddc590c633706f7c31c8c5251b53]:</summary>
- @rocket.chat/models@2.3.1-rc.0
- @rocket.chat/core-services@0.14.2-rc.0
- @rocket.chat/core-typings@8.6.0-rc.0
- @rocket.chat/rest-typings@8.6.0-rc.0
- @rocket.chat/network-broker@0.2.37-rc.0
</details>
## 0.1.5
### Patch Changes
- ([#40938](https://github.com/RocketChat/Rocket.Chat/pull/40938) by [@dionisio-bot](https://github.com/dionisio-bot)) Fixes an issue where `description` was incorrectly being used as alternative text for image attachments
- <details><summary>Updated dependencies [01a184640f635866bf4c1c612696acc4eed62311]:</summary>
- @rocket.chat/core-typings@8.5.1
- @rocket.chat/rest-typings@8.5.1
</details>
## 0.1.4
### Patch Changes
- <details><summary>Updated dependencies [f7d47dd3517ec14ca2ec5c3c95fcdf9e1e2fb8b0, 4c3984593017d59edd631bf8ae4f35f9d3c3db36, b6b04aadfcc8558f888b334e37c46a77e5816237, 4704bf81ca370f120af32185a7c55407a26f8514, 12897e25d0dc25b7373f5264d38f38a5a7444257, e45585b70a3a7b75434c88e4b2ea9af0a0764a76, b1c2668b74bfb49ebaefe2f581b2f8be5d4d1dd6]:</summary>
- @rocket.chat/models@2.3.0
- @rocket.chat/rest-typings@8.5.0
- @rocket.chat/core-typings@8.5.0
- @rocket.chat/network-broker@0.2.36
- @rocket.chat/core-services@0.14.1
</details>
## 0.1.4-rc.0
### Patch Changes
- <details><summary>Updated dependencies [f7d47dd3517ec14ca2ec5c3c95fcdf9e1e2fb8b0, 4c3984593017d59edd631bf8ae4f35f9d3c3db36, ae9f740d6af20557eac61b4af902c868b4132b49, b6b04aadfcc8558f888b334e37c46a77e5816237, 4704bf81ca370f120af32185a7c55407a26f8514, 12897e25d0dc25b7373f5264d38f38a5a7444257, e45585b70a3a7b75434c88e4b2ea9af0a0764a76, b1c2668b74bfb49ebaefe2f581b2f8be5d4d1dd6]:</summary>
- @rocket.chat/models@2.3.0-rc.0
- @rocket.chat/rest-typings@8.5.0-rc.0
- @rocket.chat/core-typings@8.5.0-rc.0
- @rocket.chat/core-services@0.14.1-rc.0
- @rocket.chat/network-broker@0.2.36-rc.0
</details>
## 0.1.3
### Patch Changes
- ([#40114](https://github.com/RocketChat/Rocket.Chat/pull/40114)) Fixes a bug preventing users to join a room after being banned and unbanned.
- <details><summary>Updated dependencies [21cd54f87de5837fe6c999a44bd15be34b9fe905, 278b84f78360e53792a2e5d7620615039a0e15e9, 12c44d2db65af9f90c741da621164f5738fc19f5, 24b3671fe61b8b09c6a1b5dc6401b503b3fb92a0, 9713af36f5c0d673f2d2093015f322341706bab0, f3fa3ee2f2e53b777de9abd466f1b76a1ec1b96c, c544b805d1c03f8eae9d061cd48838206207a7c9, 4e99ff6c1a99ac569cf444f9de9b5fe5ac3262b2, 8c0e16ca29b393cfa50b425520db48ba5a74f678, f4dfb8ddc2049692371aeb084110b5768151b5df]:</summary>
- @rocket.chat/core-services@0.14.0
- @rocket.chat/models@2.2.0
- @rocket.chat/core-typings@8.4.0
- @rocket.chat/rest-typings@8.4.0
- @rocket.chat/network-broker@0.2.35
- @rocket.chat/license@1.1.16
- @rocket.chat/http-router@7.9.23
</details>
## 0.1.3-rc.2
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.4.0-rc.2
- @rocket.chat/rest-typings@8.4.0-rc.2
- @rocket.chat/license@1.1.16-rc.2
- @rocket.chat/core-services@0.14.0-rc.2
- @rocket.chat/http-router@7.9.23-rc.2
- @rocket.chat/models@2.2.0-rc.2
- @rocket.chat/network-broker@0.2.35-rc.2
</details>
## 0.1.3-rc.1
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-services@0.14.0-rc.1
- @rocket.chat/network-broker@0.2.35-rc.1
- @rocket.chat/core-typings@8.4.0-rc.1
- @rocket.chat/rest-typings@8.4.0-rc.1
- @rocket.chat/license@1.1.16-rc.1
- @rocket.chat/http-router@7.9.23-rc.1
- @rocket.chat/models@2.2.0-rc.1
</details>
## 0.1.2
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-services@0.13.4
- @rocket.chat/core-typings@8.3.2
- @rocket.chat/rest-typings@8.3.2
- @rocket.chat/license@1.1.15
- @rocket.chat/http-router@7.9.22
- @rocket.chat/models@2.1.4
- @rocket.chat/network-broker@0.2.34
</details>
## 0.1.1
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.3.1
- @rocket.chat/rest-typings@8.3.1
- @rocket.chat/license@1.1.14
- @rocket.chat/core-services@0.13.3
- @rocket.chat/http-router@7.9.21
- @rocket.chat/models@2.1.3
- @rocket.chat/network-broker@0.2.33
</details>
## 0.1.3-rc.0
### Patch Changes
- ([#40114](https://github.com/RocketChat/Rocket.Chat/pull/40114)) Fixes a bug preventing users to join a room after being banned and unbanned.
- <details><summary>Updated dependencies [21cd54f87de5837fe6c999a44bd15be34b9fe905, 278b84f78360e53792a2e5d7620615039a0e15e9, 12c44d2db65af9f90c741da621164f5738fc19f5, 24b3671fe61b8b09c6a1b5dc6401b503b3fb92a0, 9713af36f5c0d673f2d2093015f322341706bab0, f3fa3ee2f2e53b777de9abd466f1b76a1ec1b96c, c544b805d1c03f8eae9d061cd48838206207a7c9, 4e99ff6c1a99ac569cf444f9de9b5fe5ac3262b2, 8c0e16ca29b393cfa50b425520db48ba5a74f678, f4dfb8ddc2049692371aeb084110b5768151b5df]:</summary>
- @rocket.chat/core-services@0.14.0-rc.0
- @rocket.chat/models@2.2.0-rc.0
- @rocket.chat/core-typings@8.4.0-rc.0
- @rocket.chat/rest-typings@8.4.0-rc.0
- @rocket.chat/network-broker@0.2.33-rc.0
- @rocket.chat/license@1.1.14-rc.0
- @rocket.chat/http-router@7.9.21-rc.0
</details>
## 0.1.0
### Minor Changes
- ([#39750](https://github.com/RocketChat/Rocket.Chat/pull/39750)) Adds support to name changes on federated rooms
### Patch Changes
- ([#39752](https://github.com/RocketChat/Rocket.Chat/pull/39752)) Fixes an issue on Federation where all domains ending with the pattern where being allowed to communicate, the feature is meant to work with a list, url by url
- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration
- <details><summary>Updated dependencies [602b20a8c570b895eb296ecfe39c9b7fcb12fabd, e2068892bf1ffc88b15ab71ad743cf84e5d31ed5, cd2fc208d351032c0b729755af4886665dca08b6, a4e3c1635d55ec4ce04cbde741426770e43581fb, 539659af22bc19880eda047dfc0b152472ccb65c, 5518503736b72674753e711ba4089d177ab988a5, a4341ec67d1f0413f30bbabfd292d1b0a41728b2, 40253146de8d8f83737e71b0ade7c67e0c295a28, 803b8075514de54c9ff34ba0c9aa3ee5fc3bbe61, 1361a1f4f1e3c0cc3f2a191cef8eccc12a714cde, 2a2701098536b32143003be8d267891978c708c9, 78e37dc3deae4ff05f5e33f9134c7094fd6c1330, 37acece030bc9f39bdaa86ab0130eb818332033e, d8baf395181b70fef9ce448eb509f65b66049615, ddc0ed34b03072362d166f1160104a9332b362e8, d83a1a9753464ee916845b3c88757bbcf76884a5, eae3fb3136bd0b48294c050a71b0a36d05ca02b0, 4c2e444216efd514ab406fe8e9cd127ef971d566, 722df6f60bc86c51b204e28a39acb3dc8710bdeb, 78b3fe3ef20e3a545b84551ba3f85cb40e862ba7, 98a6c58a38c053c60db2b4d53a9df0e94fecf0ba, 29b453e1def8092a8d78c28736e2bfb24229717b, 39f2e87e1caa6842e69155f033205cfdc4767b9e, c117492ad90d291a361eedc929506f557495caf7, 7c7324184589a15bf3e67b4f0c1cc222f8d48db3]:</summary>
- @rocket.chat/models@2.1.2
- @rocket.chat/rest-typings@8.3.0
- @rocket.chat/network-broker@0.2.32
- @rocket.chat/core-services@0.13.2
- @rocket.chat/core-typings@8.3.0
- @rocket.chat/http-router@7.9.20
- @rocket.chat/license@1.1.13
</details>
## 0.1.0-rc.4
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.3.0-rc.4
- @rocket.chat/rest-typings@8.3.0-rc.4
- @rocket.chat/license@1.1.13-rc.4
- @rocket.chat/core-services@0.13.2-rc.4
- @rocket.chat/http-router@7.9.20-rc.4
- @rocket.chat/models@2.1.2-rc.4
- @rocket.chat/network-broker@0.2.32-rc.4
</details>
## 0.1.0-rc.3
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.3.0-rc.3
- @rocket.chat/rest-typings@8.3.0-rc.3
- @rocket.chat/license@1.1.13-rc.3
- @rocket.chat/core-services@0.13.2-rc.3
- @rocket.chat/http-router@7.9.20-rc.3
- @rocket.chat/models@2.1.2-rc.3
- @rocket.chat/network-broker@0.2.32-rc.3
</details>
## 0.1.0-rc.2
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.3.0-rc.2
- @rocket.chat/rest-typings@8.3.0-rc.2
- @rocket.chat/license@1.1.13-rc.2
- @rocket.chat/core-services@0.13.2-rc.2
- @rocket.chat/http-router@7.9.20-rc.2
- @rocket.chat/models@2.1.2-rc.2
- @rocket.chat/network-broker@0.2.32-rc.2
</details>
## 0.1.0-rc.1
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.3.0-rc.1
- @rocket.chat/rest-typings@8.3.0-rc.1
- @rocket.chat/license@1.1.13-rc.1
- @rocket.chat/core-services@0.13.2-rc.1
- @rocket.chat/http-router@7.9.20-rc.1
- @rocket.chat/models@2.1.2-rc.1
- @rocket.chat/network-broker@0.2.32-rc.1
</details>
## 0.1.0-rc.0
### Minor Changes
- ([#39750](https://github.com/RocketChat/Rocket.Chat/pull/39750)) Adds support to name changes on federated rooms
### Patch Changes
- ([#39752](https://github.com/RocketChat/Rocket.Chat/pull/39752)) Fixes an issue on Federation where all domains ending with the pattern where being allowed to communicate, the feature is meant to work with a list, url by url
- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration
- <details><summary>Updated dependencies [602b20a8c570b895eb296ecfe39c9b7fcb12fabd, e2068892bf1ffc88b15ab71ad743cf84e5d31ed5, cd2fc208d351032c0b729755af4886665dca08b6, a4e3c1635d55ec4ce04cbde741426770e43581fb, 539659af22bc19880eda047dfc0b152472ccb65c, 5518503736b72674753e711ba4089d177ab988a5, a4341ec67d1f0413f30bbabfd292d1b0a41728b2, 40253146de8d8f83737e71b0ade7c67e0c295a28, 803b8075514de54c9ff34ba0c9aa3ee5fc3bbe61, 1361a1f4f1e3c0cc3f2a191cef8eccc12a714cde, 2a2701098536b32143003be8d267891978c708c9, 78e37dc3deae4ff05f5e33f9134c7094fd6c1330, 37acece030bc9f39bdaa86ab0130eb818332033e, d8baf395181b70fef9ce448eb509f65b66049615, ddc0ed34b03072362d166f1160104a9332b362e8, d83a1a9753464ee916845b3c88757bbcf76884a5, eae3fb3136bd0b48294c050a71b0a36d05ca02b0, 4c2e444216efd514ab406fe8e9cd127ef971d566, 722df6f60bc86c51b204e28a39acb3dc8710bdeb, 78b3fe3ef20e3a545b84551ba3f85cb40e862ba7, 98a6c58a38c053c60db2b4d53a9df0e94fecf0ba, 29b453e1def8092a8d78c28736e2bfb24229717b, 39f2e87e1caa6842e69155f033205cfdc4767b9e, c117492ad90d291a361eedc929506f557495caf7, 7c7324184589a15bf3e67b4f0c1cc222f8d48db3]:</summary>
- @rocket.chat/models@2.1.2-rc.0
- @rocket.chat/rest-typings@8.3.0-rc.0
- @rocket.chat/network-broker@0.2.32-rc.0
- @rocket.chat/core-services@0.13.2-rc.0
- @rocket.chat/core-typings@8.3.0-rc.0
- @rocket.chat/http-router@7.9.20-rc.0
- @rocket.chat/license@1.1.13-rc.0
</details>
## 0.0.14
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.2.1
- @rocket.chat/rest-typings@8.2.1
- @rocket.chat/license@1.1.12
- @rocket.chat/core-services@0.13.1
- @rocket.chat/http-router@7.9.19
- @rocket.chat/models@2.1.1
- @rocket.chat/network-broker@0.2.31
</details>
## 0.0.13
### Patch Changes
- ([#38596](https://github.com/RocketChat/Rocket.Chat/pull/38596)) Adjusts the minimum supported MongoDB version from 8.2 (Rapid Release with short support lifecycle) to 8, ensuring stable and long-term compatibility
- <details><summary>Updated dependencies [d3758a7d57ab602745369ef9d2ccdbf9271cf305, fbc4935dec220495201cf905017170d3cd1e275c, e57f15845e4df048dd2f08f11aa08215780a2c34, 11e1c51f0867a35c69ce9b6eeca25dbbe2c71872, 88da141f3c2af6f91980c7ca8b8777161f99a068, 1c474580b768358b49c93002b1277e7065df02fe, 75d089ca40248af963d7cd2a8034c3c6de6b971e, 3b003e6b69c11b280d55bcc8db2f3e4ae7a4a573, 87faec13b3c0efc3e85627f9b70c4561b7231416, 508b4a17d76dc1cd7d3a55bdba826216f51432e2, 123aebec2caa74b17d2b5dcbd2a2db2e687cf3ac]:</summary>
- @rocket.chat/core-typings@8.2.0
- @rocket.chat/models@2.1.0
- @rocket.chat/core-services@0.13.0
- @rocket.chat/rest-typings@8.2.0
- @rocket.chat/http-router@7.9.18
- @rocket.chat/license@1.1.11
- @rocket.chat/network-broker@0.2.30
</details>
## 0.0.13-rc.2
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.2.0-rc.2
- @rocket.chat/rest-typings@8.2.0-rc.2
- @rocket.chat/license@1.1.11-rc.2
- @rocket.chat/core-services@0.13.0-rc.2
- @rocket.chat/http-router@7.9.18-rc.2
- @rocket.chat/models@2.1.0-rc.2
- @rocket.chat/network-broker@0.2.30-rc.2
</details>
## 0.0.13-rc.1
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.2.0-rc.1
- @rocket.chat/rest-typings@8.2.0-rc.1
- @rocket.chat/license@1.1.11-rc.1
- @rocket.chat/core-services@0.13.0-rc.1
- @rocket.chat/http-router@7.9.18-rc.1
- @rocket.chat/models@2.1.0-rc.1
- @rocket.chat/network-broker@0.2.30-rc.1
</details>
## 0.0.13-rc.0
### Patch Changes
- ([#38596](https://github.com/RocketChat/Rocket.Chat/pull/38596)) Adjusts the minimum supported MongoDB version from 8.2 (Rapid Release with short support lifecycle) to 8, ensuring stable and long-term compatibility
- <details><summary>Updated dependencies [d3758a7d57ab602745369ef9d2ccdbf9271cf305, fbc4935dec220495201cf905017170d3cd1e275c, e57f15845e4df048dd2f08f11aa08215780a2c34, 11e1c51f0867a35c69ce9b6eeca25dbbe2c71872, 88da141f3c2af6f91980c7ca8b8777161f99a068, 1c474580b768358b49c93002b1277e7065df02fe, 75d089ca40248af963d7cd2a8034c3c6de6b971e, 3b003e6b69c11b280d55bcc8db2f3e4ae7a4a573, 87faec13b3c0efc3e85627f9b70c4561b7231416, 508b4a17d76dc1cd7d3a55bdba826216f51432e2, 123aebec2caa74b17d2b5dcbd2a2db2e687cf3ac]:</summary>
- @rocket.chat/core-typings@8.2.0-rc.0
- @rocket.chat/models@2.1.0-rc.0
- @rocket.chat/core-services@0.13.0-rc.0
- @rocket.chat/rest-typings@8.2.0-rc.0
- @rocket.chat/http-router@7.9.18-rc.0
- @rocket.chat/license@1.1.11-rc.0
- @rocket.chat/network-broker@0.2.30-rc.0
</details>
## 0.0.12
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.1.1
- @rocket.chat/rest-typings@8.1.1
- @rocket.chat/license@1.1.10
- @rocket.chat/core-services@0.12.3
- @rocket.chat/http-router@7.9.17
- @rocket.chat/models@2.0.3
- @rocket.chat/network-broker@0.2.29
</details>
## 0.0.11
### Patch Changes
- <details><summary>Updated dependencies [f4fa393fccb6abefbe6fb6550563e8ca21fc792d, 7b51ba84340480fa28e85c8f35d9d2f947d3250e, fd319d58a621d66c20029fd962ddb24a1a24ecc3, 5c3c52b1fc8b0bd52d4744614ad4d2b8e2e03937, 6654c5b481f91bdcb03d68ee0f3a12d58201137e, 020dfbcab6a940ca2e03d802d0f6b54714a34aa2, 5fa150953b86ff36face25083ed49e8c97a8044d, 05c415b94cb91907de39a39c6d277579258f334e, ccd76942bf74322f21deb855af5348856463b82a]:</summary>
- @rocket.chat/models@2.0.2
- @rocket.chat/core-services@0.12.2
- @rocket.chat/rest-typings@8.1.0
- @rocket.chat/core-typings@8.1.0
- @rocket.chat/license@1.1.9
- @rocket.chat/http-router@7.9.16
- @rocket.chat/network-broker@0.2.28
</details>
## 0.0.11-rc.2
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-services@0.12.2-rc.2
- @rocket.chat/core-typings@8.1.0-rc.2
- @rocket.chat/rest-typings@8.1.0-rc.2
- @rocket.chat/network-broker@0.2.28-rc.2
- @rocket.chat/license@1.1.9-rc.2
- @rocket.chat/http-router@7.9.16-rc.2
- @rocket.chat/models@2.0.2-rc.2
</details>
## 0.0.11-rc.1
### Patch Changes
- <details><summary>Updated dependencies [fd319d58a621d66c20029fd962ddb24a1a24ecc3]:</summary>
- @rocket.chat/rest-typings@8.1.0-rc.1
- @rocket.chat/core-services@0.12.1-rc.1
- @rocket.chat/http-router@7.9.15-rc.1
- @rocket.chat/models@2.0.1-rc.1
- @rocket.chat/network-broker@0.2.27-rc.1
- @rocket.chat/core-typings@8.1.0-rc.1
- @rocket.chat/license@1.1.8-rc.1
</details>
## 0.0.11-rc.0
### Patch Changes
- <details><summary>Updated dependencies [f4fa393fccb6abefbe6fb6550563e8ca21fc792d, 7b51ba84340480fa28e85c8f35d9d2f947d3250e, 5c3c52b1fc8b0bd52d4744614ad4d2b8e2e03937, 6654c5b481f91bdcb03d68ee0f3a12d58201137e, 020dfbcab6a940ca2e03d802d0f6b54714a34aa2, 5fa150953b86ff36face25083ed49e8c97a8044d, 05c415b94cb91907de39a39c6d277579258f334e, ccd76942bf74322f21deb855af5348856463b82a]:</summary>
- @rocket.chat/models@2.0.1-rc.0
- @rocket.chat/core-services@0.12.1-rc.0
- @rocket.chat/rest-typings@8.1.0-rc.0
- @rocket.chat/core-typings@8.1.0-rc.0
- @rocket.chat/license@1.1.8-rc.0
- @rocket.chat/http-router@7.9.15-rc.0
- @rocket.chat/network-broker@0.2.27-rc.0
</details>
## 0.0.10
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.0.1
- @rocket.chat/rest-typings@8.0.1
- @rocket.chat/license@1.1.8
- @rocket.chat/core-services@0.12.1
- @rocket.chat/http-router@7.9.15
- @rocket.chat/models@2.0.1
- @rocket.chat/network-broker@0.2.27
</details>
## 0.0.9
### Patch Changes
- ([#37791](https://github.com/RocketChat/Rocket.Chat/pull/37791)) Fixes an issue where cases of invites that were canceled or disinvited were not being handled.
- ([#37677](https://github.com/RocketChat/Rocket.Chat/pull/37677)) Fixes an issue where membership updates were not reflected when the user was the first member on their own server.
- <details><summary>Updated dependencies [347b8f973440f3e2239f79c00c2d9b430859eef2, b802430fbfdc7fa69a976468dc6dee6f3c4de26f, 872da49986436d2efa65fc42e416b45d706fd59c, 176d5eae3fb249d7d20c3e260d9fadc1a56a2fca, ac11ea05ffadeca978c794ff38d5199d9acb2c29, 1baa03cced8f94584da1224ad59cad86f219707a, f3f0b273ab49e9d1048ba43d52eb36005274905e, ac11ea05ffadeca978c794ff38d5199d9acb2c29, a1d65f493fdb039d34ef4f65d243a97931763f4b, ddc935727e9a7275813006d9dcaa7fe866610844, a5a7343a835b04812c70699be1b13e54f0e10d48, 73d9eb2783176954f42aa2cbeda8abf1d49ac260, 476a070b0099b95e4c463ee85960c4dcfbd87120, 239f4b1171bcf448cfba345cc90c4b5cd7c21afc, dc67590d14d510b069dee074c55314c56f74bb11, be80b724a636877294b5e5baa501d070941131dd, 4aa3634186d97f4144c39f6b42a65107d3d30df0, dccdcc5b4a0da4814f72a020bc4eccb8ea2497d8, 5ac1863be4c6e82666989f4b569928c0805691ff, ec0f8b435dd12c218adffa8892737c7ced4debb8, cb3c5e3455606a045f95f168dae6ed32a387697c, 5b3f93c47a03b628d613a77005e92021cd6cee4b]:</summary>
- @rocket.chat/rest-typings@8.0.0
- @rocket.chat/core-typings@8.0.0
- @rocket.chat/models@2.0.0
- @rocket.chat/core-services@0.12.0
- @rocket.chat/http-router@7.9.14
- @rocket.chat/license@1.1.7
- @rocket.chat/network-broker@0.2.26
</details>
## 0.0.9-rc.5
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.0.0-rc.5
- @rocket.chat/rest-typings@8.0.0-rc.5
- @rocket.chat/license@1.1.7-rc.5
- @rocket.chat/core-services@0.12.0-rc.5
- @rocket.chat/http-router@7.9.14-rc.5
- @rocket.chat/models@2.0.0-rc.5
- @rocket.chat/network-broker@0.2.26-rc.5
</details>
## 0.0.9-rc.4
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.0.0-rc.4
- @rocket.chat/rest-typings@8.0.0-rc.4
- @rocket.chat/license@1.1.7-rc.4
- @rocket.chat/core-services@0.12.0-rc.4
- @rocket.chat/http-router@7.9.14-rc.4
- @rocket.chat/models@2.0.0-rc.4
- @rocket.chat/network-broker@0.2.26-rc.4
</details>
## 0.0.9-rc.3
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-services@0.12.0-rc.3
- @rocket.chat/network-broker@0.2.26-rc.3
- @rocket.chat/core-typings@8.0.0-rc.3
- @rocket.chat/rest-typings@8.0.0-rc.3
- @rocket.chat/license@1.1.7-rc.3
- @rocket.chat/http-router@7.9.14-rc.3
- @rocket.chat/models@2.0.0-rc.3
</details>
## 0.0.9-rc.2
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.0.0-rc.2
- @rocket.chat/rest-typings@8.0.0-rc.2
- @rocket.chat/license@1.1.7-rc.2
- @rocket.chat/core-services@0.12.0-rc.2
- @rocket.chat/http-router@7.9.14-rc.2
- @rocket.chat/models@2.0.0-rc.2
- @rocket.chat/network-broker@0.2.26-rc.2
</details>
## 0.0.9-rc.1
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@8.0.0-rc.1
- @rocket.chat/rest-typings@8.0.0-rc.1
- @rocket.chat/license@1.1.7-rc.1
- @rocket.chat/core-services@0.12.0-rc.1
- @rocket.chat/http-router@7.9.14-rc.1
- @rocket.chat/models@2.0.0-rc.1
- @rocket.chat/network-broker@0.2.26-rc.1
</details>
## 0.0.9-rc.0
### Patch Changes
- ([#37791](https://github.com/RocketChat/Rocket.Chat/pull/37791)) Fixes an issue where cases of invites that were canceled or disinvited were not being handled.
- ([#37677](https://github.com/RocketChat/Rocket.Chat/pull/37677)) Fixes an issue where membership updates were not reflected when the user was the first member on their own server.
- <details><summary>Updated dependencies [347b8f973440f3e2239f79c00c2d9b430859eef2, b802430fbfdc7fa69a976468dc6dee6f3c4de26f, 872da49986436d2efa65fc42e416b45d706fd59c, 176d5eae3fb249d7d20c3e260d9fadc1a56a2fca, ac11ea05ffadeca978c794ff38d5199d9acb2c29, 1baa03cced8f94584da1224ad59cad86f219707a, f3f0b273ab49e9d1048ba43d52eb36005274905e, ac11ea05ffadeca978c794ff38d5199d9acb2c29, a1d65f493fdb039d34ef4f65d243a97931763f4b, ddc935727e9a7275813006d9dcaa7fe866610844, a5a7343a835b04812c70699be1b13e54f0e10d48, 73d9eb2783176954f42aa2cbeda8abf1d49ac260, 476a070b0099b95e4c463ee85960c4dcfbd87120, 239f4b1171bcf448cfba345cc90c4b5cd7c21afc, dc67590d14d510b069dee074c55314c56f74bb11, be80b724a636877294b5e5baa501d070941131dd, 4aa3634186d97f4144c39f6b42a65107d3d30df0, dccdcc5b4a0da4814f72a020bc4eccb8ea2497d8, 5ac1863be4c6e82666989f4b569928c0805691ff, ec0f8b435dd12c218adffa8892737c7ced4debb8, cb3c5e3455606a045f95f168dae6ed32a387697c, 5b3f93c47a03b628d613a77005e92021cd6cee4b]:</summary>
- @rocket.chat/rest-typings@8.0.0-rc.0
- @rocket.chat/core-typings@8.0.0-rc.0
- @rocket.chat/models@2.0.0-rc.0
- @rocket.chat/core-services@0.12.0-rc.0
- @rocket.chat/http-router@7.9.14-rc.0
- @rocket.chat/license@1.1.7-rc.0
- @rocket.chat/network-broker@0.2.26-rc.0
</details>
## 0.0.8
### Patch Changes
- <details><summary>Updated dependencies [eef2b390a8e4664ad4b974e7ea579982245efce5]:</summary>
- @rocket.chat/core-services@0.11.2
- @rocket.chat/network-broker@0.2.25
- @rocket.chat/core-typings@7.13.2
- @rocket.chat/rest-typings@7.13.2
- @rocket.chat/license@1.1.6
- @rocket.chat/http-router@7.9.13
- @rocket.chat/models@1.8.2
</details>
## 0.0.7
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.13.1
- @rocket.chat/rest-typings@7.13.1
- @rocket.chat/license@1.1.5
- @rocket.chat/core-services@0.11.1
- @rocket.chat/http-router@7.9.12
- @rocket.chat/models@1.8.1
- @rocket.chat/network-broker@0.2.24
</details>
## 0.0.6
### Patch Changes
- <details><summary>Updated dependencies [8596daf01ac84864caa63dd937971e557933d401, 83642cbe499ba399329449fb8cd652a0405c0795, 7f1b834a55b1240c226afde77713262da47f45dc, 5c7e8ec1de894e7b8eeb6e57b0c8a43bd22d2d46, 65fbcbed9f64004b953dd9d4182b3fccb8147339, 035e0c7c387d6c99703513c7c00bf37e86538b71]:</summary>
- @rocket.chat/core-services@0.11.0
- @rocket.chat/models@1.8.0
- @rocket.chat/core-typings@7.13.0
- @rocket.chat/rest-typings@7.13.0
- @rocket.chat/network-broker@0.2.23
- @rocket.chat/license@1.1.4
- @rocket.chat/http-router@7.9.11
</details>
## 0.0.6-rc.2
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.13.0-rc.2
- @rocket.chat/rest-typings@7.13.0-rc.2
- @rocket.chat/license@1.1.4-rc.2
- @rocket.chat/core-services@0.11.0-rc.2
- @rocket.chat/http-router@7.9.11-rc.2
- @rocket.chat/models@1.8.0-rc.2
- @rocket.chat/network-broker@0.2.23-rc.2
</details>
## 0.0.6-rc.1
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.13.0-rc.1
- @rocket.chat/rest-typings@7.13.0-rc.1
- @rocket.chat/license@1.1.2-rc.1
- @rocket.chat/core-services@0.11.0-rc.1
- @rocket.chat/http-router@7.9.9-rc.1
- @rocket.chat/models@1.8.0-rc.1
- @rocket.chat/network-broker@0.2.21-rc.1
</details>
## 0.0.6-rc.0
### Patch Changes
- <details><summary>Updated dependencies [8596daf01ac84864caa63dd937971e557933d401, 83642cbe499ba399329449fb8cd652a0405c0795, 7f1b834a55b1240c226afde77713262da47f45dc, 5c7e8ec1de894e7b8eeb6e57b0c8a43bd22d2d46, 65fbcbed9f64004b953dd9d4182b3fccb8147339, 035e0c7c387d6c99703513c7c00bf37e86538b71]:</summary>
- @rocket.chat/core-services@0.11.0-rc.0
- @rocket.chat/models@1.8.0-rc.0
- @rocket.chat/core-typings@7.13.0-rc.0
- @rocket.chat/rest-typings@7.13.0-rc.0
- @rocket.chat/network-broker@0.2.21-rc.0
- @rocket.chat/license@1.1.2-rc.0
- @rocket.chat/http-router@7.9.9-rc.0
</details>
## 0.0.5
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.12.2
- @rocket.chat/rest-typings@7.12.2
- @rocket.chat/license@1.1.3
- @rocket.chat/core-services@0.10.6
- @rocket.chat/http-router@7.9.10
- @rocket.chat/models@1.7.3
- @rocket.chat/network-broker@0.2.22
</details>
## 0.0.4
### Patch Changes
- <details><summary>Updated dependencies [8cbd9bfd0566cbd7d86d8b40ea7d58d6ef382742]:</summary>
- @rocket.chat/models@1.7.2
- @rocket.chat/core-services@0.10.5
- @rocket.chat/core-typings@7.12.1
- @rocket.chat/rest-typings@7.12.1
- @rocket.chat/network-broker@0.2.21
- @rocket.chat/license@1.1.2
- @rocket.chat/http-router@7.9.9
</details>
## 0.0.3
### Patch Changes
- <details><summary>Updated dependencies [48d1442a07e860fefd6991c97e94d6a9de345afe, d166e2a1ffba4e59361d5f79e8c376fca5cbf12f, 4ab2b7fdb9314538d45abdf4474ea9d2409799be, fd4f9b2523dae23518561cdcfa8d931f87d8964d]:</summary>
- @rocket.chat/rest-typings@7.12.0
- @rocket.chat/core-typings@7.12.0
- @rocket.chat/core-services@0.10.4
- @rocket.chat/http-router@7.9.8
- @rocket.chat/models@1.7.1
- @rocket.chat/license@1.1.1
- @rocket.chat/network-broker@0.2.20
</details>
## 0.0.3-rc.4
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.12.0-rc.4
- @rocket.chat/rest-typings@7.12.0-rc.4
- @rocket.chat/license@1.1.1-rc.4
- @rocket.chat/core-services@0.10.4-rc.4
- @rocket.chat/http-router@7.9.8-rc.4
- @rocket.chat/models@1.7.1-rc.4
- @rocket.chat/network-broker@0.2.20-rc.4
</details>
## 0.0.3-rc.3
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.12.0-rc.3
- @rocket.chat/rest-typings@7.12.0-rc.3
- @rocket.chat/license@1.1.1-rc.3
- @rocket.chat/core-services@0.10.4-rc.3
- @rocket.chat/http-router@7.9.8-rc.3
- @rocket.chat/models@1.7.1-rc.3
- @rocket.chat/network-broker@0.2.20-rc.3
</details>
## 0.0.3-rc.2
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.12.0-rc.2
- @rocket.chat/rest-typings@7.12.0-rc.2
- @rocket.chat/license@1.1.1-rc.2
- @rocket.chat/core-services@0.10.4-rc.2
- @rocket.chat/http-router@7.9.8-rc.2
- @rocket.chat/models@1.7.1-rc.2
- @rocket.chat/network-broker@0.2.20-rc.2
</details>
## 0.0.3-rc.1
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.12.0-rc.1
- @rocket.chat/rest-typings@7.12.0-rc.1
- @rocket.chat/license@1.1.1-rc.1
- @rocket.chat/core-services@0.10.4-rc.1
- @rocket.chat/http-router@7.9.8-rc.1
- @rocket.chat/models@1.7.1-rc.1
- @rocket.chat/network-broker@0.2.20-rc.1
</details>
## 0.0.3-rc.0
### Patch Changes
- <details><summary>Updated dependencies [48d1442a07e860fefd6991c97e94d6a9de345afe, d166e2a1ffba4e59361d5f79e8c376fca5cbf12f, 4ab2b7fdb9314538d45abdf4474ea9d2409799be, fd4f9b2523dae23518561cdcfa8d931f87d8964d]:</summary>
- @rocket.chat/rest-typings@7.12.0-rc.0
- @rocket.chat/core-typings@7.12.0-rc.0
- @rocket.chat/core-services@0.10.4-rc.0
- @rocket.chat/http-router@7.9.8-rc.0
- @rocket.chat/models@1.7.1-rc.0
- @rocket.chat/license@1.1.1-rc.0
- @rocket.chat/network-broker@0.2.20-rc.0
</details>
## 0.0.2
### Patch Changes
- <details><summary>Updated dependencies [c102712222cc18c050f45f9279f8eafcff3e7e8a, d76a5578ed0e14fae2a041c0e09d565b28630d76, 8d2ad40c46334aaf35816e52a3b28dc2ac23ce2d, ba793542644d2b725924c4cef253e7d84bc2ff71, 3e177dbd0b65d70bce8587287ba73b30170eb0f7, c5ee569cc3120bb616001fd78687dbf623a6a504, 00611aceec7f158064863f2e829c5a6698687512, c15a47ee1d55118342d0f0ece7af77f6f41132b7, 3dfb557f723a395bc1e00f2647675968c46656bc, 276c270f34293729182e9ebab61e1ff2c35398fc, b0a4602a4461200b9872b2b073ec56fa55ecb466, 80fbb2eb421253e6066207949eaf2b41871e4428, 90a1759df57814306cbc859409fe041b33db7a39, b74ab6f18ff1343f4835975f663211b005ccc5a1]:</summary>
- @rocket.chat/models@1.7.0
- @rocket.chat/rest-typings@7.11.0
- @rocket.chat/core-services@0.10.3
- @rocket.chat/core-typings@7.11.0
- @rocket.chat/license@1.1.0
- @rocket.chat/http-router@7.9.7
- @rocket.chat/network-broker@0.2.19
</details>
## 0.0.2-rc.7
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.11.0-rc.7
- @rocket.chat/rest-typings@7.11.0-rc.7
- @rocket.chat/license@1.1.0-rc.7
- @rocket.chat/core-services@0.10.3-rc.7
- @rocket.chat/http-router@7.9.7-rc.7
- @rocket.chat/models@1.7.0-rc.7
- @rocket.chat/network-broker@0.2.19-rc.7
</details>
## 0.0.2-rc.6
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.11.0-rc.6
- @rocket.chat/rest-typings@7.11.0-rc.6
- @rocket.chat/license@1.1.0-rc.6
- @rocket.chat/core-services@0.10.2-rc.6
- @rocket.chat/http-router@7.9.6-rc.6
- @rocket.chat/models@1.7.0-rc.6
- @rocket.chat/network-broker@0.2.18-rc.6
</details>
## 0.0.2-rc.5
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.11.0-rc.5
- @rocket.chat/rest-typings@7.11.0-rc.5
- @rocket.chat/license@1.1.0-rc.5
- @rocket.chat/core-services@0.10.2-rc.5
- @rocket.chat/http-router@7.9.6-rc.5
- @rocket.chat/models@1.7.0-rc.5
- @rocket.chat/network-broker@0.2.18-rc.5
</details>
## 0.0.2-rc.4
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.11.0-rc.4
- @rocket.chat/rest-typings@7.11.0-rc.4
- @rocket.chat/license@1.1.0-rc.4
- @rocket.chat/core-services@0.10.2-rc.4
- @rocket.chat/http-router@7.9.6-rc.4
- @rocket.chat/models@1.7.0-rc.4
- @rocket.chat/network-broker@0.2.18-rc.4
</details>
## 0.0.2-rc.3
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.11.0-rc.3
- @rocket.chat/rest-typings@7.11.0-rc.3
- @rocket.chat/license@1.1.0-rc.3
- @rocket.chat/core-services@0.10.2-rc.3
- @rocket.chat/http-router@7.9.6-rc.3
- @rocket.chat/models@1.7.0-rc.3
- @rocket.chat/network-broker@0.2.18-rc.3
</details>
## 0.0.2-rc.2
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.11.0-rc.2
- @rocket.chat/rest-typings@7.11.0-rc.2
- @rocket.chat/license@1.1.0-rc.2
- @rocket.chat/core-services@0.10.1-rc.2
- @rocket.chat/http-router@7.9.5-rc.2
- @rocket.chat/models@1.7.0-rc.2
- @rocket.chat/network-broker@0.2.17-rc.2
</details>
## 0.0.2-rc.1
### Patch Changes
- <details><summary>Updated dependencies []:</summary>
- @rocket.chat/core-typings@7.11.0-rc.1
- @rocket.chat/rest-typings@7.11.0-rc.1
- @rocket.chat/license@1.1.0-rc.1
- @rocket.chat/core-services@0.10.1-rc.1
- @rocket.chat/http-router@7.9.5-rc.1
- @rocket.chat/models@1.7.0-rc.1
- @rocket.chat/network-broker@0.2.17-rc.1
</details>
## 0.0.2-rc.0
### Patch Changes
- <details><summary>Updated dependencies [c102712222cc18c050f45f9279f8eafcff3e7e8a, d76a5578ed0e14fae2a041c0e09d565b28630d76, 8d2ad40c46334aaf35816e52a3b28dc2ac23ce2d, ba793542644d2b725924c4cef253e7d84bc2ff71, 3e177dbd0b65d70bce8587287ba73b30170eb0f7, c5ee569cc3120bb616001fd78687dbf623a6a504, 00611aceec7f158064863f2e829c5a6698687512, c15a47ee1d55118342d0f0ece7af77f6f41132b7, 3dfb557f723a395bc1e00f2647675968c46656bc, 276c270f34293729182e9ebab61e1ff2c35398fc, b0a4602a4461200b9872b2b073ec56fa55ecb466, 80fbb2eb421253e6066207949eaf2b41871e4428, 90a1759df57814306cbc859409fe041b33db7a39, b74ab6f18ff1343f4835975f663211b005ccc5a1]:</summary>
- @rocket.chat/models@1.7.0-rc.0
- @rocket.chat/rest-typings@7.11.0-rc.0
- @rocket.chat/core-services@0.10.1-rc.0
- @rocket.chat/core-typings@7.11.0-rc.0
- @rocket.chat/license@1.1.0-rc.0
- @rocket.chat/http-router@7.9.5-rc.0
- @rocket.chat/network-broker@0.2.17-rc.0
</details>

View File

@@ -0,0 +1,76 @@
# Federation Matrix
Rocket.Chat's Matrix federation integration package for cross-platform communication.
## Integration Tests
### Setup
Before running integration tests, add the following entries to your `/etc/hosts` file:
```
127.0.0.1 element
127.0.0.1 hs1
127.0.0.1 rc1
```
### How It Works
The integration test script builds Rocket.Chat locally, starts federation services (Rocket.Chat, Synapse, MongoDB), waits for all services to be ready, then runs end-to-end tests. The script automatically handles cleanup unless you specify otherwise.
### Available Flags
- **No flags (default)**: Builds local code and runs tests
- `--image [IMAGE]`: Uses a pre-built Docker image instead of building locally (defaults to `rocketchat/rocket.chat:latest` if no image specified)
- `--keep-running`: Keeps containers running after tests complete for manual validation
- `--element`: Includes Element web client in the test environment
- `--no-test`: Starts containers and skips running tests (useful for manual testing or debugging)
### Usage Examples
**Basic local testing:**
```bash
yarn test:integration
```
**Test with pre-built image:**
```bash
yarn test:integration --image
```
**Test with specific pre-built image:**
```bash
yarn test:integration --image rocketchat/rocket.chat:latest
```
**Keep services running for manual inspection:**
```bash
yarn test:integration --keep-running
```
**Run with Element client:**
```bash
yarn test:integration --element
```
**Start containers only (skip tests):**
```bash
yarn test:integration --no-test
```
**Start containers with Element and keep them running (skip tests):**
```bash
yarn test:integration --keep-running --element --no-test
```
**Combine flags:**
```bash
yarn test:integration --image rocketchat/rocket.chat:latest --keep-running --element
```
### Service URLs (when using --keep-running or --no-test)
- **Rocket.Chat**: https://rc1
- **Synapse**: https://hs1
- **MongoDB**: localhost:27017
- **Element**: https://element (when using --element flag)

View File

@@ -0,0 +1,165 @@
networks:
hs1-net:
rc1-net:
element-net:
services:
traefik:
image: traefik:v3.6.6
profiles:
- test
- element
command:
- '--api.insecure=true'
# - "--log.level=DEBUG"
- '--providers.docker=true'
- '--providers.docker.exposedbydefault=false'
- '--entrypoints.web.address=:80'
- '--entrypoints.websecure.address=:443'
ports:
- '80:80'
- '443:443'
- '8080:8080'
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./docker-compose/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
- ./docker-compose/traefik/dynamic_conf.yml:/etc/traefik/dynamic_conf.yml:ro
- ./docker-compose/traefik/certs:/etc/traefik/certs:ro
# - ./traefik-logs:/logs # Mount a volume for logs
networks:
# Defines and isolate one network per service to prevent inter service communication which
# would not happen in real life. The name clashing between the host and service in the same
# network (like rc1 service provided as rc1 host) is causing a bug on home server address
# resolution which tries to communicate with the container directly some times and which does
# not provide SSL neither the correct exposed ports.
hs1-net:
aliases: [hs1, rc1, rc.host]
rc1-net:
aliases: [hs1, rc1, rc.host]
element-net:
aliases: [hs1, rc1, rc.host, element]
# HomeServer 1 (synapse)
hs1:
image: matrixdotorg/synapse:v1.157.2
profiles:
- test
- element
entrypoint: |
sh -c
"update-ca-certificates &&
mkdir -p /data/media_store &&
chown -R 991:991 /data &&
/start.py &
until curl -sf http://localhost:8008/_matrix/client/versions; do
echo '=====> Waiting for Synapse...';
sleep 2;
done;
echo '';
echo '=====> Running register_new_matrix_user...';
register_new_matrix_user -u admin -p admin --admin http://localhost:8008 -c /data/homeserver.yaml | sed 's/^/=======> /';
register_new_matrix_user -u alice -p alice --admin http://localhost:8008 -c /data/homeserver.yaml | sed 's/^/=======> /';
register_new_matrix_user -u bob -p bob --admin http://localhost:8008 -c /data/homeserver.yaml | sed 's/^/=======> /';
register_new_matrix_user -u cleiton -p cleiton --admin http://localhost:8008 -c /data/homeserver.yaml | sed 's/^/=======> /';
echo '=====> Finished register_new_matrix_user.';
wait"
volumes:
- ./docker-compose/hs1/homeserver.yaml:/data/homeserver.yaml
- ./docker-compose/hs1/hs1.log.config:/data/hs1.log.config
- ./docker-compose/hs1/hs1.signing.key:/data/hs1.signing.key
- ./docker-compose/traefik/certs/ca:/usr/local/share/ca-certificates
networks:
- hs1-net
labels:
- 'traefik.enable=true'
- 'traefik.http.routers.hs1.rule=Host(`hs1`)'
- 'traefik.http.routers.hs1.entrypoints=websecure'
- 'traefik.http.routers.hs1.tls=true'
- 'traefik.http.services.hs1.loadbalancer.server.port=8008'
# Rocket.Chat rc1
rc1:
build:
context: ${ROCKETCHAT_BUILD_CONTEXT:-./test/dist}
dockerfile: ${ROCKETCHAT_DOCKERFILE:-../../../apps/meteor/.docker/Dockerfile.alpine}
target: release-standard
image: ${ROCKETCHAT_IMAGE:-rocketchat/rocket.chat:latest}
profiles:
- test
- element
environment:
ROOT_URL: https://rc1
PORT: 3000
MONGO_URL: mongodb://mongo:27017/rc1?replicaSet=rs0
NODE_EXTRA_CA_CERTS: /usr/local/share/ca-certificates/rootCA.pem
LOG_LEVEL: debug
ROCKETCHAT_LICENSE: ${ENTERPRISE_LICENSE_RC1}
OVERWRITE_SETTING_Show_Setup_Wizard: completed
OVERWRITE_SETTING_Federation_Service_Enabled: true
OVERWRITE_SETTING_Federation_Service_Domain: rc1
OVERWRITE_SETTING_Cloud_Workspace_Client_Id: temp_id
OVERWRITE_SETTING_Cloud_Workspace_Client_Secret: temp_secret
ADMIN_USERNAME: admin
ADMIN_PASS: admin
ADMIN_EMAIL: admin@admin.com
TEST_MODE: api
volumes:
- ./docker-compose/traefik/certs/ca/rootCA.crt:/usr/local/share/ca-certificates/rootCA.pem
networks:
- rc1-net
depends_on:
- mongo
labels:
- 'traefik.enable=true'
- 'traefik.http.routers.rc1.rule=Host(`rc1`)'
- 'traefik.http.routers.rc1.entrypoints=websecure'
- 'traefik.http.routers.rc1.tls=true'
- 'traefik.http.services.rc1.loadbalancer.server.port=3000'
# HTTPS Redirect
- 'traefik.http.middlewares.rc1.redirectscheme.scheme=https'
- 'traefik.http.routers.rc1-http.rule=Host(`rc1`)'
- 'traefik.http.routers.rc1-http.middlewares=rc1'
mongo:
image: mongo:${MONGODB_FEDERATION_VERSION:-8.0}
profiles:
- test
- element
restart: on-failure
ports:
- '27017:27017'
entrypoint: |
bash -c
"mongod --replSet rs0 --bind_ip_all &
sleep 2;
until mongosh --eval \"db.adminCommand('ping')\"; do
echo '=====> Waiting for Mongo...';
sleep 1;
done;
echo '=====> Initiating ReplSet...';
mongosh --eval \"rs.initiate({_id: 'rs0', members: [{ _id: 0, host: 'mongo:27017' }]})\";
echo '=====> Initiating ReplSet done...';
wait"
networks:
- rc1-net
element:
image: vectorim/element-web
profiles:
- element
# ports:
# - "8080:80"
volumes:
- ./docker-compose/element/config.json:/app/config.json
networks:
- element-net
labels:
- 'traefik.enable=true'
- 'traefik.http.routers.element.rule=Host(`element`)'
- 'traefik.http.routers.element.entrypoints=websecure'
- 'traefik.http.routers.element.tls=true'
- 'traefik.http.services.element.loadbalancer.server.port=80'
# HTTPS Redirect
- 'traefik.http.middlewares.element.redirectscheme.scheme=https'
- 'traefik.http.routers.element-http.rule=Host(`element`)'
- 'traefik.http.routers.element-http.middlewares=element'

View File

@@ -0,0 +1,19 @@
{
"default_server_config": {
"m.homeserver": {
"base_url": "https://hs1",
"server_name": "hs1"
},
"m.identity_server": {
"base_url": "https://vector.im"
}
},
"disable_password_rules": true,
"disable_custom_urls": false,
"disable_guests": false,
"brand": "Element",
"enable_presence_by_hs_url": {
"https://hs1": false,
"https://rc1": false
}
}

View File

@@ -0,0 +1,154 @@
# Configuration file for Synapse.
server_name: "hs1"
pid_file: /data/hs1.pid
listeners:
- port: 8008
tls: false
type: http
x_forwarded: true
resources:
- names: [client, federation]
use_x_forwarded_for: true
serve_server_wellknown: true
database:
name: sqlite3
args:
database: /data/hs1.db
log_config: "/data/hs1.log.config"
media_store_path: /data/media_store
registration_shared_secret: "3l5H.Y5urc5@gKwYMe2^abk@nf.U_M6iyMgP,j&OL6pcSGrUQE"
report_stats: false
macaroon_secret_key: "3II&OLx=,6RcC&E~iksWi:tUU4J.~reK:MdU&JAPhlaGIL*+IA"
form_secret: "B2EuUMKfg&.BsU;#DZ9;E,baB^wc=lV--F_PwkRLZvk=LHk+U4"
signing_key_path: "/data/hs1.signing.key"
trusted_key_servers: []
# - server_name: "matrix.org"
# accept_keys_insecurely: true
federation_client_minimum_tls_version: 1.2
federation_verify_certificates: false
suppress_key_server_warning: true
enable_registration: true
enable_registration_without_verification: true
allow_public_rooms_over_federation: true
password_config:
enabled: true
localdb_enabled: true
policy:
minimum_length: 1
require_lowercase: false
require_uppercase: false
require_digit: false
require_symbol: false
# Optionally, add your Docker network to the whitelist
ip_range_whitelist:
- '0.0.0.0/0'
# --------------------------------------------------------------------
# Development rate limits (set very high)
# --------------------------------------------------------------------
# Messaging
rc_message:
per_second: 10000
burst_count: 10000
# Registration
rc_registration:
per_second: 10000
burst_count: 10000
# Registration token validity checks
rc_registration_token_validity:
per_second: 10000
burst_count: 10000
# Room creation
rc_room_creation:
per_second: 10000
burst_count: 10000
# Login (IP, account, and failed-attempt buckets)
rc_login:
address:
per_second: 10000
burst_count: 10000
account:
per_second: 10000
burst_count: 10000
failed_attempts:
per_second: 10000
burst_count: 10000
# Joins
rc_joins:
local:
per_second: 10000
burst_count: 10000
remote:
per_second: 10000
burst_count: 10000
# Limit recent joins per room
rc_joins_per_room:
per_second: 10000
burst_count: 10000
# Invites
rc_invites:
per_room:
per_second: 10000
burst_count: 10000
per_user:
per_second: 10000
burst_count: 10000
# 3PID validation (email/phone)
rc_3pid_validation:
per_second: 10000
burst_count: 10000
# To-device and key requests
rc_to_device:
per_second: 10000
burst_count: 10000
rc_key_requests:
per_second: 10000
burst_count: 10000
# Presence / typing / receipts / read markers
rc_presence:
per_second: 10000
burst_count: 10000
rc_typing:
per_second: 10000
burst_count: 10000
rc_read_receipts:
per_second: 10000
burst_count: 10000
rc_read_markers:
per_second: 10000
burst_count: 10000
# Room-admin redactions
rc_admin_redaction:
per_second: 10000
burst_count: 10000
# Federation (uses *different* keys, not per_second/burst_count)
rc_federation:
window_size: 10000 # ms
sleep_limit: 10000
sleep_delay: 0 # ms
reject_limit: 10000
concurrent: 10000
# vim:ft=yaml

View File

@@ -0,0 +1,39 @@
version: 1
formatters:
precise:
format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'
handlers:
console:
class: logging.StreamHandler
formatter: precise
loggers:
# This is just here so we can leave `loggers` in the config regardless of whether
# we configure other loggers below (avoid empty yaml dict error).
_placeholder:
level: "INFO"
synapse.storage.SQL:
# beware: increasing this to DEBUG will make synapse log sensitive
# information such as access tokens.
level: INFO
root:
level: INFO
handlers: [console]
disable_existing_loggers: false

View File

@@ -0,0 +1 @@
ed25519 a_HDhg WntaJ4JP5WbZZjDShjeuwqCybQ5huaZAiowji7tnIEw

View File

@@ -0,0 +1,18 @@
# Allow only the certificates needed for CI testing
# Override the root .gitignore rule for *.pem
# Allow rc1 certificates
!rc1.pem
!rc1-key.pem
# Allow hs1 certificates
!hs1.pem
!hs1-key.pem
# Allow element certificates
!element.pem
!element-key.pem
# Keep ignoring all other .pem files
*.pem

View File

@@ -0,0 +1,30 @@
-----BEGIN CERTIFICATE-----
MIIFLDCCA5SgAwIBAgIRAMEZ3b7sw0tDnhAcUN+76PkwDQYJKoZIhvcNAQELBQAw
ga0xHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTFBMD8GA1UECww4cm9j
a2V0Y2hhdEBSb2NrZXRDaGF0cy1NYWNCb29rLVByby0yLmxvY2FsIChSb2NrZXQu
Q2hhdCkxSDBGBgNVBAMMP21rY2VydCByb2NrZXRjaGF0QFJvY2tldENoYXRzLU1h
Y0Jvb2stUHJvLTIubG9jYWwgKFJvY2tldC5DaGF0KTAeFw0yNDExMzAxMTQyMDVa
Fw0zNDExMzAxMTQyMDVaMIGtMR4wHAYDVQQKExVta2NlcnQgZGV2ZWxvcG1lbnQg
Q0ExQTA/BgNVBAsMOHJvY2tldGNoYXRAUm9ja2V0Q2hhdHMtTWFjQm9vay1Qcm8t
Mi5sb2NhbCAoUm9ja2V0LkNoYXQpMUgwRgYDVQQDDD9ta2NlcnQgcm9ja2V0Y2hh
dEBSb2NrZXRDaGF0cy1NYWNCb29rLVByby0yLmxvY2FsIChSb2NrZXQuQ2hhdCkw
ggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQDUSzYDKSavpVKGEG0M2WQb
LShnhwqx+TZRlncn85RNJNkVooovw0x4Q104Rmo3xOS2fwaKC5fgX1T+KNvTFFVz
dyEhsGPYBSQLD27nQ1pgO5RDMsOC54dSoFLq6SbRZXpKBjiYGvB9sQ6mkSk+DWIX
EpacbgPsmJma8pm5C03SZI8HSlUYhSKYdjF6ph/6gBeRMUu5emnx3GRYS5GMGYZJ
XxUF2HbCYeGoubbc6UvLdtKyWmo5qL0gIVfoNTlJK/s61QzFDY9pA6ULoMBwocPh
KW/1RFODSPYyjwVgckXGil3jcWHVpwLVdZXsOWu+2sbZpCfkXNGrasDm6NBjKJK+
uxM8+2zz/7yZX+SOh0bVK0St8R1Khl+jLC/xwGA4tVYT04S2f+1OWT+dqfCWHNjq
7GOcs1AQIvh8Yntx+1gwRIqcirGQCTtvzsF+zKWPmh1vip9fBkVL/6N4JK3XALiI
c6DImZHKG/6Ezw3fCilTV/MTnXeRazRyNozigJoQd5sCAwEAAaNFMEMwDgYDVR0P
AQH/BAQDAgIEMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFPMCCoABwDeC
i5pWH8Fm1EKCREu1MA0GCSqGSIb3DQEBCwUAA4IBgQCzSvcM8hPFFT0bzd9VYAoF
wKHDRJJfj3YgVY+ogfxiAvn8S/n2BBPHnPMLstGUTnNdd4Nr0QYot1H0H23QdS3m
gHbodVmp3+Ylc51RepCOqjT4nL6nCYcXr4DrDbIExIT+YyLRlrGyChEwn0AfdvFO
6+kfN6Jj0yT2uGTw0yz9x6Cnl0wJozhubcPEahc/lyhjxkYFA0YujNhS5ZPk33sH
jVJC2k3vSLD/wS6vYq+4A2yUB4KlVLVLUxEakzK5fEoAkyh2wnFAKa8uVcwU2Fz5
mEJv4KmW8SpBTQYXILCZFXLO1ON9Ge7yYFquLMAo5QVrnwVm5EFgN2+Lnw6n8h+M
OhoPw4II46EEbMFN42aVV1jwIasIitO0eXG9XB+ZaPA64xcwOzRK2g65IhjPBrLi
MiugUT/fILCBkogBwctcpfkm6+hbVM1xLkkcUOgX6+nGSg8KvpRPWqx7bn2uoa5E
uT4+6tdAtAXFCdbIk8pMsfz3fFLjL5BBDtPfa+7mnyw=
-----END CERTIFICATE-----

View File

@@ -0,0 +1,13 @@
tls:
certificates:
# synapse1
- certFile: "/etc/traefik/certs/hs1.pem"
keyFile: "/etc/traefik/certs/hs1-key.pem"
# rocket.chat rc1 (container)
- certFile: "/etc/traefik/certs/rc1.pem"
keyFile: "/etc/traefik/certs/rc1-key.pem"
# element
- certFile: "/etc/traefik/certs/element.pem"
keyFile: "/etc/traefik/certs/element-key.pem"

View File

@@ -0,0 +1,20 @@
entryPoints:
web:
address: ":80"
websecure:
address: ":443"
providers:
docker:
exposedByDefault: false
file:
filename: "/etc/traefik/dynamic_conf.yml"
api:
dashboard: false
insecure: false
# accessLog:
# filePath: "/logs/access.log"
# bufferingSize: 100
# format: json

View File

@@ -0,0 +1,63 @@
/**
* Jest configuration for federation integration tests.
*
* Extends the base server preset with federation-specific settings including
* extended timeouts for distributed system operations, proper module transformation
* for Matrix SDK dependencies, and global teardown for resource cleanup.
*/
import server from '@rocket.chat/jest-presets/server';
import type { Config } from 'jest';
function qaseRunTitle(): string {
const title = ['Federation E2E Tests'];
if (process.env.PR_NUMBER) {
title.push(`PR ${process.env.PR_NUMBER}`);
}
if (process.env.GITHUB_RUN_ID) {
title.push(`Run ${process.env.GITHUB_RUN_ID}`);
}
title.push(new Date().toISOString());
return title.join(' - ');
}
export default {
preset: server.preset,
transformIgnorePatterns: [
'<rootDir>/node_modules/@babel',
'<rootDir>/node_modules/@jest',
'/node_modules/(?!marked|@testing-library|matrix-js-sdk|@vector-im)/',
],
// Federation-specific configuration
testMatch: ['<rootDir>/tests/end-to-end/**/*.spec.ts'],
testTimeout: 30000, // 30 seconds timeout for federation tests
forceExit: true, // Force Jest to exit after tests complete
detectOpenHandles: true, // Detect open handles that prevent Jest from exiting
globalTeardown: '<rootDir>/tests/teardown.ts',
verbose: false,
silent: false,
reporters: [
'default',
...(process.env.QASE_TESTOPS_JEST_API_TOKEN
? [
[
'jest-qase-reporter',
{
mode: 'testops',
testops: {
api: { token: process.env.QASE_TESTOPS_JEST_API_TOKEN },
project: process.env.QASE_PROJECT || 'RC',
run: {
title: qaseRunTitle(),
complete: true,
},
},
},
] as [string, { [x: string]: unknown }],
]
: []),
] as Config['reporters'],
} satisfies Config;

View File

@@ -0,0 +1,14 @@
import server from '@rocket.chat/jest-presets/server';
import type { Config } from 'jest';
export default {
preset: server.preset,
transformIgnorePatterns: [
'<rootDir>/node_modules/@babel',
'<rootDir>/node_modules/@jest',
// sanitize-html vendors its own (ESM-only) htmlparser2 + friends under a nested node_modules
'/node_modules/(?!marked|@testing-library/|(dom-serializer|domelementtype|domhandler|domutils|entities|htmlparser2)/|sanitize-html/node_modules/)',
],
// Exclude integration/e2e tests from unit test runs
testPathIgnorePatterns: ['<rootDir>/tests/end-to-end/'],
} satisfies Config;

View File

@@ -0,0 +1,58 @@
{
"name": "@rocket.chat/federation-matrix",
"version": "0.2.1",
"private": true,
"main": "./dist/index.js",
"typings": "./dist/index.d.ts",
"files": [
"/dist"
],
"scripts": {
"build": "rm -rf dist && tsc -p tsconfig.build.json",
"dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"test": "jest",
"test:integration": "./tests/scripts/run-integration-tests.sh",
"test:federation": "jest --config jest.config.federation.ts",
"testunit": "jest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@rocket.chat/core-services": "workspace:^",
"@rocket.chat/core-typings": "workspace:^",
"@rocket.chat/emitter": "workspace:~",
"@rocket.chat/federation-sdk": "0.7.0",
"@rocket.chat/http-router": "workspace:^",
"@rocket.chat/license": "workspace:^",
"@rocket.chat/models": "workspace:^",
"@rocket.chat/network-broker": "workspace:^",
"@rocket.chat/rest-typings": "workspace:^",
"emojibase": "^16.0.0",
"emojibase-data": "^16.0.3",
"lodash.debounce": "^4.0.8",
"marked": "^16.1.2",
"mem": "^8.1.1",
"mongodb": "6.16.0",
"pino": "10.3.1",
"reflect-metadata": "^0.2.2",
"sanitize-html": "~2.17.7",
"tsyringe": "^4.10.0",
"tweetnacl": "^1.0.3"
},
"devDependencies": {
"@rocket.chat/ddp-client": "workspace:^",
"@types/lodash.debounce": "^4.0.9",
"@types/node": "~24.13.2",
"@types/sanitize-html": "~2.16.1",
"eslint": "~9.39.5",
"jest": "~30.2.0",
"jest-qase-reporter": "^2.4.0",
"matrix-js-sdk": "^38.4.0",
"pino-pretty": "13.1.3",
"typescript": "~5.9.3"
},
"volta": {
"extends": "../../../package.json"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
import { createHash } from 'node:crypto';
import { federationSDK } from '@rocket.chat/federation-sdk';
import { Router } from '@rocket.chat/http-router';
import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv';
const WellKnownServerResponseSchema = {
type: 'object',
properties: {
'm.server': {
type: 'string',
description: 'Matrix server address with port',
},
},
required: ['m.server'],
};
const isWellKnownServerResponseProps = ajv.compile(WellKnownServerResponseSchema);
// TODO: After changing the domain setting this route is still reporting the old domain until the server is restarted
// TODO: this is wrong, is siteurl !== domain this path should return 404. this path is to discover the final address, domain being the "proxy" and siteurl the final destination, if domain is different, well-known should be served there, not here.
export const getWellKnownRoutes = () => {
// GET /.well-known/matrix/server
// https://spec.matrix.org/v1.19/server-server-api/#getwell-knownmatrixserver
return new Router('/matrix').get(
'/server',
{
response: {
200: isWellKnownServerResponseProps,
},
tags: ['Well-Known'],
license: ['federation'],
},
async (c) => {
const responseData = federationSDK.getWellKnownHostData();
const etag = createHash('sha256').update(JSON.stringify(responseData)).digest('hex');
c.header('ETag', etag);
c.header('Content-Type', 'application/json');
return {
body: responseData,
statusCode: 200,
};
},
);
};

View File

@@ -0,0 +1,114 @@
import type { Router } from '@rocket.chat/http-router';
import { ajv, ajvQuery } from '@rocket.chat/rest-typings';
import { logger } from '../../logger';
export type ClientRouter = Router<'/client', any>;
// Logs an error and returns the matching Matrix 500 response. Use inside handler-local
// catch blocks that swallow the error. The same message is used for both the log and
// the response body, avoiding duplication.
export const internalError = (msg: string, err?: unknown, context?: Record<string, unknown>) => {
logger.error({ msg, err, ...context });
return {
statusCode: 500 as const,
body: { errcode: 'M_UNKNOWN', error: msg },
};
};
// The federation SDK throws an Error with name 'UnknownRoomError' (message `Room <id> does not exist`)
// when a room isn't known to this homeserver. The class isn't exported for `instanceof`, so we match on
// `.name` — the same check the SDK uses internally.
export const isUnknownRoomError = (err: unknown): err is Error => err instanceof Error && err.name === 'UnknownRoomError';
// Matrix response for a room this homeserver doesn't know about. Mirrors Synapse: it must be a 4xx (never
// 5xx) so bridges like matrix-bifrost treat it as terminal and stop retrying, instead of hammering the
// endpoint every 100ms as if it were a transient server fault.
export const roomNotFound = () =>
({
statusCode: 403 as const,
body: {
errcode: 'M_FORBIDDEN',
error: "You aren't a member of the room and weren't previously a member of the room.",
},
}) as const;
// Logs a warning and returns the matching Matrix 501 response. Use for endpoints/branches
// that are deliberately not implemented yet, so hits on those paths stay visible in the logs.
export const notImplemented = (msg: string, context?: Record<string, unknown>) => {
logger.warn({ msg, ...context });
return {
statusCode: 501 as const,
body: { errcode: 'M_UNRECOGNIZED', error: msg },
};
};
export const tags = ['Federation'];
export const license: ['federation'] = ['federation'];
export const MATRIX_USER_ID_PATTERN = '^@[A-Za-z0-9_=\\/.+-]+:(.+)$';
export const MATRIX_ROOM_ID_PATTERN = '^![A-Za-z0-9_=\\/.+-]+:(.+)$';
const MatrixErrorSchema = {
type: 'object',
properties: {
errcode: { type: 'string' },
error: { type: 'string' },
},
required: ['errcode', 'error'],
};
export const isMatrixErrorProps = ajv.compile(MatrixErrorSchema);
// Matches only the literal `{}` these endpoints return per spec.
const EmptyObjectResponseSchema = {
type: 'object',
additionalProperties: false,
};
export const isEmptyObjectResponseProps = ajv.compile(EmptyObjectResponseSchema);
const ImpersonationQuerySchema = {
type: 'object',
properties: {
user_id: {
type: 'string',
pattern: MATRIX_USER_ID_PATTERN,
description: 'Matrix user ID to impersonate; must be in the AS user namespace',
},
},
required: [],
};
export const isImpersonationQueryProps = ajvQuery.compile<{ user_id?: string }>(ImpersonationQuerySchema);
const RoomIdParamsSchema = {
type: 'object',
properties: {
roomId: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN },
},
required: ['roomId'],
};
export const isRoomIdParamsProps = ajv.compile(RoomIdParamsSchema);
const UserIdParamsSchema = {
type: 'object',
properties: {
userId: { type: 'string', pattern: MATRIX_USER_ID_PATTERN },
},
required: ['userId'],
};
export const isUserIdParamsProps = ajv.compile(UserIdParamsSchema);
const ProfileFieldParamsSchema = {
type: 'object',
properties: {
userId: { type: 'string', pattern: MATRIX_USER_ID_PATTERN },
field: { type: 'string' },
},
required: ['userId', 'field'],
};
export const isProfileFieldParamsProps = ajv.compile(ProfileFieldParamsSchema);

View File

@@ -0,0 +1,218 @@
import { federationSDK } from '@rocket.chat/federation-sdk';
import { ajv } from '@rocket.chat/rest-typings';
import type { ClientRouter } from './_shared';
import { isMatrixErrorProps, license, tags } from './_shared';
import { createOrUpdateFederatedUser } from '../../../helpers/createOrUpdateFederatedUser';
import { decodeXmppUserId, isFullXmppUserId, parseXmppUserId } from '../../../helpers/parseXmppUserId';
import { validateFederatedUsername } from '../../../helpers/validateFederatedUsername';
import { logger } from '../../logger';
import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated';
const RegisterBodySchema = {
type: 'object',
properties: {
type: { type: 'string' },
username: { type: 'string' },
},
required: ['type', 'username'],
additionalProperties: true,
};
const isRegisterBodyProps = ajv.compile(RegisterBodySchema);
const RegisterResponseSchema = {
type: 'object',
properties: {
user_id: { type: 'string' },
home_server: { type: 'string' },
access_token: { type: 'string' },
},
required: ['user_id'],
};
const isRegisterResponseProps = ajv.compile(RegisterResponseSchema);
const WhoamiResponseSchema = {
type: 'object',
properties: {
user_id: { type: 'string' },
device_id: { type: 'string' },
is_guest: { type: 'boolean' },
},
required: ['user_id'],
additionalProperties: true,
};
const isWhoamiResponseProps = ajv.compile(WhoamiResponseSchema);
export const addAccountRoutes = (router: ClientRouter) => {
router
// POST /_matrix/client/v3/register
.post(
'/v3/register',
{
body: isRegisterBodyProps,
response: {
200: isRegisterResponseProps,
400: isMatrixErrorProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
501: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const body = await c.req.json();
if (body.type !== 'm.login.application_service') {
return {
statusCode: 400,
body: {
errcode: 'M_FORBIDDEN',
error: 'AS registration requires auth.type=m.login.application_service',
},
};
}
const serverName = federationSDK.getConfig('serverName');
// An application service may only register users that no *other* bridge exclusively claims.
// Registering within its own exclusive namespace is fine; another bridge's is M_EXCLUSIVE.
const appService = c.get('appService') as ReturnType<typeof federationSDK.getRegistrationByAsToken>;
const isReservedByAnotherAppService = (candidate: string): boolean => {
const mxid = candidate.startsWith('@') ? candidate : `@${candidate}:${serverName}`;
const owner = federationSDK.isExclusiveNamespace('users', mxid);
return Boolean(owner && owner.registration._id !== appService?.registration._id);
};
const decoded = decodeXmppUserId(body.username);
if (!isFullXmppUserId(decoded)) {
// The spec defines `username` as the desired localpart; normalize either form to the
// fully-qualified MXID, which is what gets stored and what `user_id` must carry.
const withSigil = body.username.startsWith('@') ? body.username : `@${body.username}`;
const userId: string = withSigil.includes(':') ? withSigil : `${withSigil}:${serverName}`;
if (!validateFederatedUsername(userId)) {
logger.warn({ msg: 'Malformed user id during AS registration', username: body.username });
return {
statusCode: 400,
body: {
errcode: 'M_INVALID_USERNAME',
error: 'Could not derive a username from the provided user id',
},
};
}
if (isReservedByAnotherAppService(userId)) {
return {
statusCode: 400,
body: {
errcode: 'M_EXCLUSIVE',
error: 'Username is in an exclusive namespace of another application service',
},
};
}
await createOrUpdateFederatedUser({
username: userId,
origin: serverName,
asId: appService?.registration._id,
});
return {
statusCode: 200,
body: {
user_id: userId,
},
};
}
let decodedUsername;
try {
decodedUsername = parseXmppUserId(decoded);
} catch (error) {
logger.warn({ msg: 'Malformed XMPP user id during AS registration', username: body.username, err: error });
return {
statusCode: 400,
body: {
errcode: 'M_INVALID_USERNAME',
error: 'Could not derive a username from the provided XMPP user id',
},
};
}
if (!decodedUsername.resource) {
logger.warn({ msg: 'Could not derive resource from full XMPP user id during AS registration', username: body.username });
return {
statusCode: 400,
body: {
errcode: 'M_INVALID_USERNAME',
error: 'Could not derive a username from the provided XMPP user id',
},
};
}
const username = `@${decodedUsername.resource}:${serverName}`;
if (!validateFederatedUsername(username)) {
logger.warn({ msg: 'Malformed user id derived from XMPP user id during AS registration', username: body.username });
return {
statusCode: 400,
body: {
errcode: 'M_INVALID_USERNAME',
error: 'Could not derive a username from the provided XMPP user id',
},
};
}
if (isReservedByAnotherAppService(username)) {
return {
statusCode: 400,
body: {
errcode: 'M_EXCLUSIVE',
error: 'Username is in an exclusive namespace of another application service',
},
};
}
await createOrUpdateFederatedUser({
username,
origin: serverName,
});
return {
statusCode: 200,
body: {
user_id: username,
},
};
},
)
// GET /_matrix/client/v3/account/whoami
.get(
'/v3/account/whoami',
{
response: {
200: isWhoamiResponseProps,
401: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const username = c.get('impersonatedUserId') as string;
return {
statusCode: 200,
body: {
user_id: username,
},
};
},
);
};

View File

@@ -0,0 +1,60 @@
import { ajv } from '@rocket.chat/rest-typings';
import type { ClientRouter } from './_shared';
import {
MATRIX_ROOM_ID_PATTERN,
notImplemented,
isEmptyObjectResponseProps,
isImpersonationQueryProps,
isMatrixErrorProps,
license,
tags,
} from './_shared';
import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated';
const RoomAliasParamsSchema = {
type: 'object',
properties: {
roomAlias: { type: 'string' },
},
required: ['roomAlias'],
};
const isRoomAliasParamsProps = ajv.compile(RoomAliasParamsSchema);
const DirectoryPutBodySchema = {
type: 'object',
properties: {
room_id: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN },
},
required: ['room_id'],
additionalProperties: true,
};
const isDirectoryPutBodyProps = ajv.compile(DirectoryPutBodySchema);
export const addDirectoryRoutes = (router: ClientRouter) => {
router
// PUT /_matrix/client/v3/directory/room/:roomAlias
.put(
'/v3/directory/room/:roomAlias',
{
params: isRoomAliasParamsProps,
query: isImpersonationQueryProps,
body: isDirectoryPutBodyProps,
response: {
200: isEmptyObjectResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
501: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async () => {
// TODO(federation-sdk): createAlias(alias, roomId, sender)
return notImplemented('Room alias creation not yet implemented');
},
);
};

View File

@@ -0,0 +1,29 @@
import { Router } from '@rocket.chat/http-router';
import { addAccountRoutes } from './account';
import { addDirectoryRoutes } from './directory';
import { addClientMediaRoutes } from './media';
import { addPresenceRoutes } from './presence';
import { addProfileRoutes } from './profile';
import { addRoomsLifecycleRoutes } from './rooms-lifecycle';
import { addRoomsMessagingRoutes } from './rooms-messaging';
import { addRoomsStateRoutes } from './rooms-state';
import { addUserRoutes } from './user';
import { addVersionsRoutes } from './versions';
export const getClientRoutes = () => {
const router = new Router('/client');
addVersionsRoutes(router);
addAccountRoutes(router);
addProfileRoutes(router);
addPresenceRoutes(router);
addDirectoryRoutes(router);
addRoomsLifecycleRoutes(router);
addRoomsStateRoutes(router);
addRoomsMessagingRoutes(router);
addUserRoutes(router);
addClientMediaRoutes(router);
return router;
};

View File

@@ -0,0 +1,238 @@
import { Readable } from 'node:stream';
import { Upload } from '@rocket.chat/core-services';
import { ajv, ajvQuery } from '@rocket.chat/rest-typings';
import type { ClientRouter } from './_shared';
import { internalError, isMatrixErrorProps, license, tags } from './_shared';
import { MatrixMediaService } from '../../../services/MatrixMediaService';
import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated';
const MediaParamsSchema = {
type: 'object',
properties: {
serverName: { type: 'string' },
mediaId: { type: 'string' },
},
required: ['serverName', 'mediaId'],
};
const isMediaParamsProps = ajv.compile(MediaParamsSchema);
const ThumbnailQuerySchema = {
type: 'object',
properties: {
// union type lists rather than `oneOf`: ajvQuery coerces between number and string, so both
// `oneOf` branches would match a numeric value and fail validation
width: { type: ['number', 'string'] },
height: { type: ['number', 'string'] },
method: { type: 'string', enum: ['crop', 'scale'] },
timeout_ms: { type: ['number', 'string'] },
},
};
const isThumbnailQueryProps = ajvQuery.compile<{
width?: number | string;
height?: number | string;
method?: 'crop' | 'scale';
timeout_ms?: number | string;
}>(ThumbnailQuerySchema);
const BufferResponseSchema = {
type: 'object',
description: 'multipart/mixed response',
additionalProperties: true,
};
const isBufferResponseProps = ajv.compile(BufferResponseSchema);
const ConfigResponseSchema = {
type: 'object',
properties: {
'm.upload.size': { type: 'number' },
},
additionalProperties: true,
};
const isConfigResponseProps = ajv.compile(ConfigResponseSchema);
// The resize uses sharp with fit 'contain', which upscales past the original size,
// so unbounded dimensions would let a client force arbitrarily large allocations.
const MAX_THUMBNAIL_DIMENSION = 2048;
const SECURITY_HEADERS = {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Content-Security-Policy': "default-src 'none'; img-src 'self'; media-src 'self'",
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
};
// Builds an RFC 5987-compliant Content-Disposition header. Always emits the
// ASCII-safe `filename=` for legacy clients and additionally emits
// `filename*=UTF-8''…` when the name contains non-ASCII characters.
function contentDispositionHeader(disposition: 'inline' | 'attachment', fileName: string): string {
const asciiFallback = fileName.replace(/[^\x20-\x7E]/g, '_').replace(/["\\]/g, '_');
const isAscii = asciiFallback === fileName;
if (isAscii) {
return `${disposition}; filename="${asciiFallback}"`;
}
// RFC 5987 requires percent-encoding `*'()` too, which encodeURIComponent leaves raw;
// a stray `'` is especially bad since it's the delimiter of the charset'lang'value syntax.
const rfc5987Value = encodeURIComponent(fileName).replace(/[*'()]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
return `${disposition}; filename="${asciiFallback}"; filename*=UTF-8''${rfc5987Value}`;
}
// MSC3916 says authenticated media downloads should be multipart/mixed, but the
// matrix-bot-sdk used by appservice bridges (e.g. matrix-bifrost) doesn't parse
// that envelope — it just streams the response body straight through to the
// downstream client, which then sees raw multipart text. To stay compatible
// with those bridges, we serve raw bytes here, same as the legacy
// /_matrix/media/v3/download endpoint.
export const addClientMediaRoutes = (router: ClientRouter) => {
router
// GET /_matrix/client/v1/media/download/:serverName/:mediaId
.get(
'/v1/media/download/:serverName/:mediaId',
{
params: isMediaParamsProps,
response: {
200: isBufferResponseProps,
401: isMatrixErrorProps,
404: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
try {
const serverName = c.req.param('serverName') as string;
const mediaId = c.req.param('mediaId') as string;
const file = await MatrixMediaService.getLocalFileForMatrixNode(mediaId, serverName);
if (!file) {
return {
statusCode: 404,
body: { errcode: 'M_NOT_FOUND', error: 'Media not found' },
};
}
const buffer = await MatrixMediaService.getLocalFileBuffer(file);
const mimeType = file.type || 'application/octet-stream';
const fileName = file.name || mediaId;
return {
statusCode: 200,
headers: {
...SECURITY_HEADERS,
'content-type': mimeType,
'content-length': String(buffer.length),
'content-disposition': contentDispositionHeader('attachment', fileName),
},
body: buffer,
};
} catch (error) {
return internalError('Failed to download media', error);
}
},
)
// GET /_matrix/client/v1/media/thumbnail/:serverName/:mediaId
.get(
'/v1/media/thumbnail/:serverName/:mediaId',
{
params: isMediaParamsProps,
query: isThumbnailQueryProps,
response: {
200: isBufferResponseProps,
400: isMatrixErrorProps,
401: isMatrixErrorProps,
404: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
try {
const serverName = c.req.param('serverName') as string;
const mediaId = c.req.param('mediaId') as string;
const width = Number(c.req.query('width'));
const height = Number(c.req.query('height'));
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return {
statusCode: 400,
body: { errcode: 'M_BAD_REQUEST', error: 'Invalid width or height' },
};
}
if (width > MAX_THUMBNAIL_DIMENSION || height > MAX_THUMBNAIL_DIMENSION) {
return {
statusCode: 400,
body: { errcode: 'M_BAD_REQUEST', error: 'Requested dimensions exceed maximum allowed' },
};
}
const file = await MatrixMediaService.getLocalFileForMatrixNode(mediaId, serverName);
if (!file) {
return {
statusCode: 404,
body: { errcode: 'M_NOT_FOUND', error: 'Media not found' },
};
}
if (!file.type?.startsWith('image/')) {
return {
statusCode: 400,
body: { errcode: 'M_BAD_REQUEST', error: 'Thumbnails are only supported for images' },
};
}
// `method` is validated but not honored: thumbnails are always scaled (the spec
// default), never cropped. Spec-wise thumbnailing is best-effort, so serving a
// scaled image for a `crop` request is acceptable.
const stream = await Upload.streamUploadedFile({ file, imageResizeOpts: { width, height } });
const mimeType = file.type || 'image/jpeg';
const fileName = file.name || mediaId;
return {
statusCode: 200,
headers: {
...SECURITY_HEADERS,
'content-type': mimeType,
'content-disposition': contentDispositionHeader('inline', fileName),
},
body: Readable.toWeb(stream),
};
} catch (error) {
return internalError('Failed to generate media thumbnail', error);
}
},
)
// GET /_matrix/client/v1/media/config
.get(
'/v1/media/config',
{
response: {
200: isConfigResponseProps,
401: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async () => {
return {
statusCode: 200,
body: {
'm.upload.size': 50 * 1024 * 1024,
},
};
},
);
};

View File

@@ -0,0 +1,45 @@
import { ajv } from '@rocket.chat/rest-typings';
import type { ClientRouter } from './_shared';
import { isMatrixErrorProps, isUserIdParamsProps, license, tags } from './_shared';
import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated';
const PresenceResponseSchema = {
type: 'object',
properties: {
presence: { type: 'string', enum: ['online', 'offline', 'unavailable'] },
last_active_ago: { type: 'number' },
status_msg: { type: 'string' },
currently_active: { type: 'boolean' },
},
required: ['presence'],
additionalProperties: true,
};
const isPresenceResponseProps = ajv.compile(PresenceResponseSchema);
export const addPresenceRoutes = (router: ClientRouter) => {
router.get(
'/v3/presence/:userId/status',
{
params: isUserIdParamsProps,
response: {
200: isPresenceResponseProps,
401: isMatrixErrorProps,
501: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async () => {
// TODO(federation-sdk): expose presence service via federationSDK.getPresence(userId)
return {
statusCode: 200,
body: {
presence: 'offline',
},
};
},
);
};

View File

@@ -0,0 +1,238 @@
import { federationSDK } from '@rocket.chat/federation-sdk';
import { Users } from '@rocket.chat/models';
import { ajv } from '@rocket.chat/rest-typings';
import type { ClientRouter } from './_shared';
import {
internalError,
notImplemented,
isEmptyObjectResponseProps,
isImpersonationQueryProps,
isMatrixErrorProps,
isProfileFieldParamsProps,
isUserIdParamsProps,
license,
tags,
} from './_shared';
import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated';
const ProfileGetResponseSchema = {
type: 'object',
properties: {
displayname: { type: 'string', nullable: true },
avatar_url: { type: 'string', nullable: true },
},
additionalProperties: true,
};
const isProfileGetResponseProps = ajv.compile(ProfileGetResponseSchema);
const DisplaynameBodySchema = {
type: 'object',
properties: {
displayname: { type: 'string', nullable: true },
},
required: ['displayname'],
};
const isDisplaynameBodyProps = ajv.compile(DisplaynameBodySchema);
const AvatarUrlBodySchema = {
type: 'object',
properties: {
avatar_url: { type: 'string', nullable: true },
},
required: ['avatar_url'],
};
const isAvatarUrlBodyProps = ajv.compile(AvatarUrlBodySchema);
const ALLOWED_PROFILE_FIELDS = ['displayname', 'avatar_url'];
export const addProfileRoutes = (router: ClientRouter) => {
router
// GET /_matrix/client/v3/profile/:userId
.get(
'/v3/profile/:userId',
{
params: isUserIdParamsProps,
response: {
200: isProfileGetResponseProps,
401: isMatrixErrorProps,
404: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const userId = c.req.param('userId');
try {
// TODO maybe this can be a query to our models instead of going through the federation-sdk
const profile = await federationSDK.queryProfile(userId);
if (!profile) {
return {
statusCode: 404,
body: {
errcode: 'M_NOT_FOUND',
error: 'Profile not found',
},
};
}
return {
statusCode: 200,
body: {
displayname: profile.displayname,
...(profile.avatar_url ? { avatar_url: profile.avatar_url } : {}),
},
};
} catch (error) {
return internalError('Failed to fetch profile', error, { userId });
}
},
)
// GET /_matrix/client/v3/profile/:userId/:field
.get(
'/v3/profile/:userId/:field',
{
params: isProfileFieldParamsProps,
response: {
200: isProfileGetResponseProps,
400: isMatrixErrorProps,
401: isMatrixErrorProps,
404: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const userId = c.req.param('userId');
const field = c.req.param('field');
if (!field || !ALLOWED_PROFILE_FIELDS.includes(field)) {
return {
statusCode: 400,
body: { errcode: 'M_INVALID_PARAM', error: 'Unknown profile field' },
};
}
try {
// TODO maybe this can be a query to our models instead of going through the federation-sdk
const profile = await federationSDK.queryProfile(userId);
if (!profile) {
return {
statusCode: 404,
body: {
errcode: 'M_NOT_FOUND',
error: 'Profile not found',
},
};
}
return {
statusCode: 200,
body: {
[field]: profile[field as keyof typeof profile],
},
};
} catch (error) {
return internalError('Failed to fetch profile', error, { userId, field });
}
},
)
// PUT /_matrix/client/v3/profile/:userId/displayname
.put(
'/v3/profile/:userId/displayname',
{
params: isUserIdParamsProps,
query: isImpersonationQueryProps,
body: isDisplaynameBodyProps,
response: {
200: isEmptyObjectResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
404: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const userId = c.req.param('userId');
const username = c.get('impersonatedUserId') as string;
// A bridge always targets the user it impersonates, so user_id mirrors the path
// param (both in packed form for XMPP puppets). The exception is the bot setting
// its own displayname, where user_id is omitted and impersonatedUserId holds the
// bot's MXID verbatim.
if (userId !== (c.req.query('user_id') ?? username)) {
return {
statusCode: 403,
body: { errcode: 'M_FORBIDDEN', error: 'Cannot set the displayname of another user' },
};
}
const body = await c.req.json();
const user = await Users.findOneByUsername(username);
if (!user) {
return {
statusCode: 404,
body: {
errcode: 'M_NOT_FOUND',
error: 'User not found',
},
};
}
await Users.setName(user._id, body.displayname);
return {
statusCode: 200,
body: {},
};
},
)
// PUT /_matrix/client/v3/profile/:userId/avatar_url
.put(
'/v3/profile/:userId/avatar_url',
{
params: isUserIdParamsProps,
query: isImpersonationQueryProps,
body: isAvatarUrlBodyProps,
response: {
200: isEmptyObjectResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
404: isMatrixErrorProps,
501: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const userId = c.req.param('userId');
const username = c.get('impersonatedUserId') as string;
const user = await Users.findOneByUsername(username);
if (!user) {
return {
statusCode: 404,
body: {
errcode: 'M_NOT_FOUND',
error: 'User not found',
},
};
}
// TODO(federation-sdk): setUserProfile(userId, {displayname?, avatar_url?}) — global, propagates to rooms
return notImplemented('Global profile update not yet implemented', { userId });
},
);
};

View File

@@ -0,0 +1,378 @@
import { Room } from '@rocket.chat/core-services';
import type { IRoomNativeFederated } from '@rocket.chat/core-typings';
import type { RoomID, UserID } from '@rocket.chat/federation-sdk';
import { federationSDK } from '@rocket.chat/federation-sdk';
import { Rooms, Users } from '@rocket.chat/models';
import { ajv } from '@rocket.chat/rest-typings';
import type { ClientRouter } from './_shared';
import {
MATRIX_ROOM_ID_PATTERN,
MATRIX_USER_ID_PATTERN,
internalError,
isEmptyObjectResponseProps,
isImpersonationQueryProps,
isMatrixErrorProps,
isRoomIdParamsProps,
license,
notImplemented,
tags,
} from './_shared';
import { getFederatedRoomName } from '../../../helpers/getFederatedRoomName';
import { logger } from '../../logger';
import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated';
const CreateRoomBodySchema = {
type: 'object',
properties: {
room_alias_name: { type: 'string' },
name: { type: 'string' },
topic: { type: 'string' },
visibility: { type: 'string', enum: ['public', 'private'] },
preset: { type: 'string', enum: ['private_chat', 'trusted_private_chat', 'public_chat'] },
invite: {
type: 'array',
items: { type: 'string', pattern: MATRIX_USER_ID_PATTERN },
},
is_direct: { type: 'boolean' },
initial_state: {
type: 'array',
items: {
type: 'object',
properties: {
type: { type: 'string' },
content: { type: 'object' },
},
required: ['type', 'content'],
additionalProperties: true,
},
},
},
additionalProperties: true,
};
const isCreateRoomBodyProps = ajv.compile(CreateRoomBodySchema);
const CreateRoomResponseSchema = {
type: 'object',
properties: {
room_id: { type: 'string' },
room_alias: { type: 'string' },
},
required: ['room_id'],
};
const isCreateRoomResponseProps = ajv.compile(CreateRoomResponseSchema);
const JoinParamsSchema = {
type: 'object',
properties: {
roomIdOrAlias: { type: 'string' },
},
required: ['roomIdOrAlias'],
};
const isJoinParamsProps = ajv.compile(JoinParamsSchema);
const JoinResponseSchema = {
type: 'object',
properties: {
room_id: { type: 'string' },
},
required: ['room_id'],
};
const isJoinResponseProps = ajv.compile(JoinResponseSchema);
const RoomLeaveBodySchema = {
type: 'object',
properties: {
reason: { type: 'string' },
},
additionalProperties: true,
};
const isRoomLeaveBodyProps = ajv.compile(RoomLeaveBodySchema);
const InviteBodySchema = {
type: 'object',
properties: {
user_id: { type: 'string', pattern: MATRIX_USER_ID_PATTERN },
reason: { type: 'string' },
},
required: ['user_id'],
additionalProperties: true,
};
const isInviteBodyProps = ajv.compile(InviteBodySchema);
const KickBodySchema = {
type: 'object',
properties: {
user_id: { type: 'string', pattern: MATRIX_USER_ID_PATTERN },
reason: { type: 'string' },
},
required: ['user_id'],
additionalProperties: true,
};
const isKickBodyProps = ajv.compile(KickBodySchema);
const RoomLeaveParamsSchema = {
type: 'object',
properties: {
roomId: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN },
},
required: ['roomId'],
};
const isRoomLeaveParamsProps = ajv.compile(RoomLeaveParamsSchema);
export const addRoomsLifecycleRoutes = (router: ClientRouter) => {
router
// POST /_matrix/client/v3/createRoom
.post(
'/v3/createRoom',
{
query: isImpersonationQueryProps,
body: isCreateRoomBodyProps,
response: {
200: isCreateRoomResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const senderUsername = c.get('impersonatedUserId') as UserID;
const body = await c.req.json();
const serverName = federationSDK.getConfig('serverName');
const user = await Users.findOneByUsername(senderUsername, { projection: { _id: 1 } });
if (!user) {
// Mirrors Synapse: 4xx so bridges treat it as terminal (register the user and retry)
// instead of hammering a 5xx as a transient fault.
return {
statusCode: 403,
body: { errcode: 'M_FORBIDDEN', error: 'Application service has not registered this user' },
};
}
// The human-facing name supplied by the Matrix client, which may be empty or
// contain characters RC does not allow in a room slug.
const displayName = body.name || body.room_alias_name || '';
// get join room from initial_state (for now since this is what bifrost sends)
const joinRule =
body.initial_state?.find((e: any) => e.type === 'm.room.join_rules')?.content?.join_rule === 'public' ? 'public' : 'invite';
try {
const result = await federationSDK.createRoomV2({
name: displayName,
alias: body.room_alias_name,
owner: senderUsername,
joinRule,
});
// TODO after creating the federated room we must create the room for rocket.chat as well
const room = await Rooms.findOne({ 'federation.mrid': result.room_id });
if (!room) {
// Derive the RC name (slug) from the Matrix room id rather than the supplied
// name, which may be empty or contain characters RC rejects. Mirrors the invite
// flow in events/member.ts and keeps the human-facing name in `fname`.
const name = getFederatedRoomName(result.room_id);
await Room.create<IRoomNativeFederated>(user._id, {
type: joinRule === 'public' ? 'c' : 'p',
name,
members: [senderUsername],
options: {
forceNew: true, // an invite means the room does not exist yet
creator: user._id,
},
extraData: {
federated: true,
federation: {
version: 1,
mrid: result.room_id,
origin: serverName,
},
fname: displayName || name,
},
});
}
for (const invitee of (body.invite ?? []) as string[]) {
try {
await federationSDK.inviteUserToRoom(invitee as UserID, result.room_id, senderUsername, body.is_direct);
} catch (err) {
// The room already exists at this point; failing the whole request would make
// the caller believe creation failed and retry, duplicating the room.
logger.error({ msg: 'Failed to invite user to newly created room', invitee, roomId: result.room_id, err });
}
}
return {
statusCode: 200,
body: {
room_id: result.room_id,
},
};
} catch (error) {
return internalError('Failed to create room', error);
}
},
)
// POST /_matrix/client/v3/join/:roomIdOrAlias
.post(
'/v3/join/:roomIdOrAlias',
{
params: isJoinParamsProps,
query: isImpersonationQueryProps,
response: {
200: isJoinResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
501: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomIdOrAlias = c.req.param('roomIdOrAlias') as string;
// TODO(federation-sdk): expose alias resolution so this endpoint can also accept room aliases
if (!roomIdOrAlias.startsWith('!')) {
return notImplemented('Joining a room by alias is not yet implemented', { roomIdOrAlias });
}
const roomId = roomIdOrAlias as RoomID;
await federationSDK.joinUser(roomId, c.get('impersonatedUserId'));
return {
statusCode: 200,
body: {
room_id: roomId,
},
};
},
)
// POST /_matrix/client/v3/rooms/:roomId/leave
.post(
'/v3/rooms/:roomId/leave',
{
params: isRoomLeaveParamsProps,
query: isImpersonationQueryProps,
body: isRoomLeaveBodyProps,
response: {
200: isEmptyObjectResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
404: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomId = c.req.param('roomId') as RoomID;
const senderUsername = c.get('impersonatedUserId') as UserID;
try {
await federationSDK.leaveRoom(roomId, senderUsername);
return {
statusCode: 200,
body: {},
};
} catch (error: any) {
if (error?.message?.toLowerCase?.().includes('not found')) {
return {
statusCode: 404,
body: {
errcode: 'M_NOT_FOUND',
error: 'Room not found',
},
};
}
return internalError('Failed to leave room', error, { roomId, senderId: senderUsername });
}
},
)
// POST /_matrix/client/v3/rooms/:roomId/invite
.post(
'/v3/rooms/:roomId/invite',
{
params: isRoomIdParamsProps,
query: isImpersonationQueryProps,
body: isInviteBodyProps,
response: {
200: isEmptyObjectResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomId = c.req.param('roomId') as RoomID;
const senderUsername = c.get('impersonatedUserId') as UserID;
const body = await c.req.json();
try {
await federationSDK.inviteUserToRoom(body.user_id as UserID, roomId, senderUsername);
return {
statusCode: 200,
body: {},
};
} catch (error) {
return internalError('Failed to invite user', error, { roomId, senderUsername });
}
},
)
// POST /_matrix/client/v3/rooms/:roomId/kick
.post(
'/v3/rooms/:roomId/kick',
{
params: isRoomIdParamsProps,
query: isImpersonationQueryProps,
body: isKickBodyProps,
response: {
200: isEmptyObjectResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomId = c.req.param('roomId') as RoomID;
const senderUsername = c.get('impersonatedUserId') as UserID;
const body = await c.req.json();
try {
await federationSDK.kickUser(roomId, body.user_id as UserID, senderUsername, body.reason);
return {
statusCode: 200,
body: {},
};
} catch (error) {
return internalError('Failed to kick user', error, { roomId, senderUsername });
}
},
);
};

View File

@@ -0,0 +1,418 @@
import { api, FederationMatrix, Room } from '@rocket.chat/core-services';
import type { IUser } from '@rocket.chat/core-typings';
import { isUserNativeFederated } from '@rocket.chat/core-typings';
import type { EventID, FileMessageContent, FileMessageType, PduForType, RoomID, UserID } from '@rocket.chat/federation-sdk';
import { federationSDK } from '@rocket.chat/federation-sdk';
import { Rooms, Users } from '@rocket.chat/models';
import { ajv, ajvQuery } from '@rocket.chat/rest-typings';
import type { ClientRouter } from './_shared';
import {
MATRIX_ROOM_ID_PATTERN,
MATRIX_USER_ID_PATTERN,
internalError,
notImplemented,
isEmptyObjectResponseProps,
isImpersonationQueryProps,
isMatrixErrorProps,
isRoomIdParamsProps,
license,
tags,
} from './_shared';
import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated';
const SendEventParamsSchema = {
type: 'object',
properties: {
roomId: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN },
eventType: { type: 'string' },
txnId: { type: 'string' },
},
required: ['roomId', 'eventType', 'txnId'],
};
const isSendEventParamsProps = ajv.compile(SendEventParamsSchema);
const SendEventBodySchema = {
type: 'object',
additionalProperties: true,
};
const isSendEventBodyProps = ajv.compile(SendEventBodySchema);
const SendEventResponseSchema = {
type: 'object',
properties: {
event_id: { type: 'string' },
},
required: ['event_id'],
};
const isSendEventResponseProps = ajv.compile(SendEventResponseSchema);
const MessagesQuerySchema = {
type: 'object',
properties: {
user_id: { type: 'string' },
from: { type: 'string' },
to: { type: 'string' },
dir: { type: 'string', enum: ['b', 'f'] },
// a union type list rather than `oneOf`: ajvQuery coerces between number and string, so both
// `oneOf` branches would match a numeric value and fail validation
limit: { type: ['number', 'string'] },
filter: { type: 'string' },
},
};
const isMessagesQueryProps = ajvQuery.compile<{
user_id?: string;
from?: string;
to?: string;
dir?: 'b' | 'f';
limit?: number | string;
filter?: string;
}>(MessagesQuerySchema);
const MessagesResponseSchema = {
type: 'object',
properties: {
chunk: { type: 'array', items: { type: 'object', additionalProperties: true } },
start: { type: 'string' },
end: { type: 'string' },
},
additionalProperties: true,
};
const isMessagesResponseProps = ajv.compile(MessagesResponseSchema);
const TypingParamsSchema = {
type: 'object',
properties: {
roomId: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN },
userId: { type: 'string', pattern: MATRIX_USER_ID_PATTERN },
},
required: ['roomId', 'userId'],
};
const isTypingParamsProps = ajv.compile(TypingParamsSchema);
const TypingBodySchema = {
type: 'object',
properties: {
typing: { type: 'boolean' },
timeout: { type: 'number' },
},
required: ['typing'],
additionalProperties: true,
};
const isTypingBodyProps = ajv.compile(TypingBodySchema);
const ReceiptParamsSchema = {
type: 'object',
properties: {
roomId: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN },
eventId: { type: 'string' },
},
required: ['roomId', 'eventId'],
};
const isReceiptParamsProps = ajv.compile(ReceiptParamsSchema);
const ReceiptBodySchema = {
type: 'object',
additionalProperties: true,
};
const isReceiptBodyProps = ajv.compile(ReceiptBodySchema);
export const addRoomsMessagingRoutes = (router: ClientRouter) => {
router
// PUT /_matrix/client/v3/rooms/:roomId/send/:eventType/:txnId
.put(
'/v3/rooms/:roomId/send/:eventType/:txnId',
{
params: isSendEventParamsProps,
query: isImpersonationQueryProps,
body: isSendEventBodyProps,
response: {
200: isSendEventResponseProps,
400: isMatrixErrorProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
500: isMatrixErrorProps,
501: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomId = c.req.param('roomId') as RoomID;
const eventType = c.req.param('eventType');
const senderUsername = c.get('impersonatedUserId') as UserID;
const body = await c.req.json();
if (eventType === 'org.matrix.bridge.ping') {
try {
const event = await federationSDK.sendCustomEvent(roomId, eventType, body, senderUsername);
return {
statusCode: 200,
body: {
event_id: event.eventId,
},
};
} catch (error) {
return internalError('Failed to send ping event', error, { roomId, senderUsername });
}
}
if (eventType !== 'm.room.message') {
// TODO: support additional event types (m.reaction, m.room.redaction, etc.)
return notImplemented('Only m.room.message is supported in v1', { eventType });
}
if (typeof body.body !== 'string' || typeof body.msgtype !== 'string') {
return {
statusCode: 400,
body: {
errcode: 'M_BAD_JSON',
error: 'm.room.message requires string fields body and msgtype',
},
};
}
const fileMsgtypes: FileMessageType[] = ['m.image', 'm.file', 'm.audio', 'm.video'];
const isFileMessage = fileMsgtypes.includes(body.msgtype);
if (isFileMessage && typeof body.url !== 'string') {
return {
statusCode: 400,
body: {
errcode: 'M_BAD_JSON',
error: `${body.msgtype} requires a string url field`,
},
};
}
// TODO: deduplicate by txnId to handle bridge retries
try {
if (isFileMessage) {
const fileContent: FileMessageContent = {
body: body.body,
msgtype: body.msgtype,
url: body.url,
info: body.info,
};
const event = await federationSDK.sendFileMessage(roomId, fileContent, senderUsername);
await FederationMatrix.saveFederationMessage({
event: event.event as PduForType<'m.room.message'>,
event_id: event.eventId,
});
return {
statusCode: 200,
body: {
event_id: event.eventId,
},
};
}
const event = await federationSDK.sendMessage(roomId, body.body, body.formatted_body ?? body.body, senderUsername);
await FederationMatrix.saveFederationMessage({ event: event.event as PduForType<'m.room.message'>, event_id: event.eventId });
return {
statusCode: 200,
body: {
event_id: event.eventId,
},
};
} catch (error) {
return internalError('Failed to send message', error, { roomId });
}
},
)
// GET /_matrix/client/v3/rooms/:roomId/messages
.get(
'/v3/rooms/:roomId/messages',
{
params: isRoomIdParamsProps,
query: isMessagesQueryProps,
response: {
200: isMessagesResponseProps,
401: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomId = c.req.param('roomId') as RoomID;
const fromParam = c.req.query('from');
const limitParam = c.req.query('limit');
const limit = limitParam ? Number(limitParam) || 10 : 10;
try {
if (!fromParam || limit <= 0) {
return {
statusCode: 200,
body: {
chunk: [],
start: '',
end: '',
},
};
}
const result = await federationSDK.getBackfillEvents(roomId, [fromParam] as EventID[], limit);
return {
statusCode: 200,
body: {
chunk: result.pdus,
start: fromParam,
end: '',
},
};
} catch (error) {
return internalError('Failed to fetch messages', error, { roomId });
}
},
)
// PUT /_matrix/client/v3/rooms/:roomId/typing/:userId
.put(
'/v3/rooms/:roomId/typing/:userId',
{
params: isTypingParamsProps,
query: isImpersonationQueryProps,
body: isTypingBodyProps,
response: {
200: isEmptyObjectResponseProps,
400: isMatrixErrorProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
404: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomId = c.req.param('roomId') as RoomID;
const username = c.get('impersonatedUserId');
const body = await c.req.json();
if (!username) {
return {
statusCode: 400,
body: {
errcode: 'M_BAD_REQUEST',
error: 'Missing userId parameter',
},
};
}
try {
const matrixRoom = await Rooms.findOne({ 'federation.mrid': roomId }, { projection: { _id: 1 } });
if (!matrixRoom) {
return {
statusCode: 404,
body: {
errcode: 'M_NOT_FOUND',
error: 'Room not found',
},
};
}
const user = await Users.findOneByUsername<Pick<IUser, 'name' | 'username' | 'federated' | 'federation'>>(username, {
projection: { name: 1, username: 1, federated: 1, federation: 1 },
});
if (!user || !isUserNativeFederated(user)) {
return {
statusCode: 404,
body: {
errcode: 'M_NOT_FOUND',
error: 'User not found',
},
};
}
void api.broadcast('user.activity', {
user: user.name || user.username,
isTyping: body.typing,
roomId: matrixRoom._id,
});
await federationSDK.sendTypingNotification(roomId, username, body.typing === true);
return {
statusCode: 200,
body: {},
};
} catch (error) {
return internalError('Failed to send typing notification', error, { roomId, userId: username });
}
},
)
// POST /_matrix/client/v3/rooms/:roomId/receipt/m.read/:eventId
.post(
'/v3/rooms/:roomId/receipt/m.read/:eventId',
{
params: isReceiptParamsProps,
query: isImpersonationQueryProps,
body: isReceiptBodyProps,
response: {
200: isEmptyObjectResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
404: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomId = c.req.param('roomId') as RoomID;
const senderUsername = c.get('impersonatedUserId') as string;
try {
const matrixUser = await Users.findOneByUsername(senderUsername);
if (!matrixUser) {
return {
statusCode: 404,
body: {
errcode: 'M_NOT_FOUND',
error: 'User not found',
},
};
}
const matrixRoom = await Rooms.findOne({ 'federation.mrid': roomId });
if (!matrixRoom) {
return {
statusCode: 404,
body: {
errcode: 'M_NOT_FOUND',
error: 'Room not found',
},
};
}
await Room.markAsRead(matrixRoom, matrixUser._id);
return {
statusCode: 200,
body: {},
};
} catch (error) {
return internalError('Failed to send read receipt', error, { roomId, senderUsername });
}
},
);
};

View File

@@ -0,0 +1,348 @@
import type { PersistentEventBase, RoomID, UserID } from '@rocket.chat/federation-sdk';
import { federationSDK } from '@rocket.chat/federation-sdk';
import { ajv } from '@rocket.chat/rest-typings';
import type { ClientRouter } from './_shared';
import {
MATRIX_ROOM_ID_PATTERN,
internalError,
isUnknownRoomError,
notImplemented,
roomNotFound,
isImpersonationQueryProps,
isMatrixErrorProps,
isRoomIdParamsProps,
license,
tags,
} from './_shared';
import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated';
const JoinedMembersResponseSchema = {
type: 'object',
properties: {
joined: {
type: 'object',
additionalProperties: {
type: 'object',
properties: {
display_name: { type: 'string', nullable: true },
avatar_url: { type: 'string', nullable: true },
},
additionalProperties: true,
},
},
},
required: ['joined'],
};
const isJoinedMembersResponseProps = ajv.compile(JoinedMembersResponseSchema);
const StateArrayResponseSchema = {
type: 'array',
items: { type: 'object', additionalProperties: true },
};
const isStateArrayResponseProps = ajv.compile(StateArrayResponseSchema);
const StateEventParamsSchema = {
type: 'object',
properties: {
roomId: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN },
eventType: { type: 'string' },
stateKey: { type: 'string' },
},
required: ['roomId', 'eventType'],
};
const isStateEventParamsProps = ajv.compile(StateEventParamsSchema);
const StateContentResponseSchema = {
type: 'object',
additionalProperties: true,
};
const isStateContentResponseProps = ajv.compile(StateContentResponseSchema);
const PutStateBodySchema = {
type: 'object',
additionalProperties: true,
};
const isPutStateBodyProps = ajv.compile(PutStateBodySchema);
const PutStateResponseSchema = {
type: 'object',
properties: {
event_id: { type: 'string' },
},
required: ['event_id'],
};
const isPutStateResponseProps = ajv.compile(PutStateResponseSchema);
const getRoomStateEvent = async (roomId: RoomID, eventType: string, stateKey = '') => {
try {
const state = await federationSDK.getLatestRoomState(roomId);
const key = `${eventType}:${stateKey}`;
let pe: PersistentEventBase | undefined;
for (const [k, v] of state) {
if (k === key) {
pe = v;
break;
}
}
if (!pe) {
return {
statusCode: 404 as const,
body: {
errcode: 'M_NOT_FOUND',
error: 'State event not found',
},
};
}
return {
statusCode: 200 as const,
body: pe.getContent(),
};
} catch (error) {
if (isUnknownRoomError(error)) {
return roomNotFound();
}
return internalError('Failed to fetch state event', error);
}
};
const putRoomStateEvent = async (
roomId: RoomID,
eventType: string,
senderUsername: UserID,
body: Record<string, unknown>,
stateKey = '',
) => {
try {
// The supported event types are all empty-state-key events per spec; writing them in
// response to a keyed request would silently target the wrong state.
if (stateKey) {
return notImplemented('State events with a non-empty state key are not yet implemented', { roomId, eventType, stateKey });
}
if (eventType === 'm.room.name' && typeof body.name === 'string') {
const event = await federationSDK.updateRoomName(roomId, body.name, senderUsername);
return {
statusCode: 200 as const,
body: { event_id: event.eventId },
};
}
if (eventType === 'm.room.topic' && typeof body.topic === 'string') {
const event = await federationSDK.setRoomTopic(roomId, senderUsername, body.topic);
return {
statusCode: 200 as const,
body: { event_id: event.eventId },
};
}
// TODO: extend SDK to send arbitrary state events
return notImplemented(`State event type ${eventType} not yet implemented`, { roomId, eventType, senderUsername });
} catch (error) {
return internalError('Failed to send state event', error, { roomId, eventType, senderUsername });
}
};
export const addRoomsStateRoutes = (router: ClientRouter) => {
router
// GET /_matrix/client/v3/rooms/:roomId/joined_members
.get(
'/v3/rooms/:roomId/joined_members',
{
params: isRoomIdParamsProps,
response: {
200: isJoinedMembersResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomId = c.req.param('roomId') as RoomID;
try {
const state = await federationSDK.getLatestRoomState(roomId);
const joined: Record<string, { display_name?: string; avatar_url?: string }> = {};
for (const [key, pe] of state) {
if (!key.startsWith('m.room.member:')) continue;
const content = pe.getContent() as { membership?: string; displayname?: string; avatar_url?: string };
if (content?.membership !== 'join') continue;
const userId = pe.stateKey;
if (!userId) continue;
joined[userId] = {
...(content.displayname ? { display_name: content.displayname } : {}),
...(content.avatar_url ? { avatar_url: content.avatar_url } : {}),
};
}
return {
statusCode: 200,
body: { joined },
};
} catch (error) {
if (isUnknownRoomError(error)) {
return roomNotFound();
}
return internalError('Failed to fetch joined members', error, { roomId });
}
},
)
// GET /_matrix/client/v3/rooms/:roomId/state
.get(
'/v3/rooms/:roomId/state',
{
params: isRoomIdParamsProps,
response: {
200: isStateArrayResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomId = c.req.param('roomId') as RoomID;
try {
const state = await federationSDK.getLatestRoomState(roomId);
const events: unknown[] = [];
for (const pe of state.values()) {
events.push(pe.event);
}
return {
statusCode: 200,
body: events,
};
} catch (error) {
if (isUnknownRoomError(error)) {
return roomNotFound();
}
return internalError('Failed to fetch room state', error, { roomId });
}
},
)
// GET /_matrix/client/v3/rooms/:roomId/state/:eventType/
// The optional-param route below (`:stateKey?`) matches `/state/:eventType` and
// `/state/:eventType/:stateKey`, but NOT the trailing-slash form `/state/:eventType/`
// (an empty final segment). Bridges request that trailing-slash URL for the
// default (empty) state key, so this explicit route handles it with stateKey=''.
.get(
'/v3/rooms/:roomId/state/:eventType/',
{
params: isStateEventParamsProps,
response: {
200: isStateContentResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
404: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomId = c.req.param('roomId') as RoomID;
const eventType = c.req.param('eventType') as string;
return getRoomStateEvent(roomId, eventType);
},
)
// GET /_matrix/client/v3/rooms/:roomId/state/:eventType/:stateKey?
.get(
'/v3/rooms/:roomId/state/:eventType/:stateKey?',
{
params: isStateEventParamsProps,
response: {
200: isStateContentResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
404: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomId = c.req.param('roomId') as RoomID;
const eventType = c.req.param('eventType') as string;
const stateKey = c.req.param('stateKey') ?? '';
return getRoomStateEvent(roomId, eventType, stateKey);
},
)
// PUT /_matrix/client/v3/rooms/:roomId/state/:eventType/
// Same trailing-slash note as the GET above: the optional-param route (`:stateKey?`)
// doesn't match an empty final segment, so this explicit route handles it with stateKey=''.
.put(
'/v3/rooms/:roomId/state/:eventType/',
{
params: isStateEventParamsProps,
query: isImpersonationQueryProps,
body: isPutStateBodyProps,
response: {
200: isPutStateResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
500: isMatrixErrorProps,
501: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomId = c.req.param('roomId') as RoomID;
const eventType = c.req.param('eventType') as string;
const senderUsername = c.get('impersonatedUserId') as UserID;
const body = await c.req.json();
return putRoomStateEvent(roomId, eventType, senderUsername, body);
},
)
// PUT /_matrix/client/v3/rooms/:roomId/state/:eventType/:stateKey?
// The state key is optional per spec: `PUT /state/:eventType` targets the empty state key.
.put(
'/v3/rooms/:roomId/state/:eventType/:stateKey?',
{
params: isStateEventParamsProps,
query: isImpersonationQueryProps,
body: isPutStateBodyProps,
response: {
200: isPutStateResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
500: isMatrixErrorProps,
501: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomId = c.req.param('roomId') as RoomID;
const eventType = c.req.param('eventType') as string;
const stateKey = c.req.param('stateKey') ?? '';
const senderUsername = c.get('impersonatedUserId') as UserID;
const body = await c.req.json();
return putRoomStateEvent(roomId, eventType, senderUsername, body, stateKey);
},
);
};

View File

@@ -0,0 +1,87 @@
import type { RoomID } from '@rocket.chat/federation-sdk';
import { federationSDK } from '@rocket.chat/federation-sdk';
import { Users } from '@rocket.chat/models';
import { ajv } from '@rocket.chat/rest-typings';
import type { ClientRouter } from './_shared';
import {
MATRIX_ROOM_ID_PATTERN,
MATRIX_USER_ID_PATTERN,
internalError,
isEmptyObjectResponseProps,
isImpersonationQueryProps,
isMatrixErrorProps,
license,
tags,
} from './_shared';
import { isAppServiceAuthenticatedMiddleware } from '../../middlewares/isAppServiceAuthenticated';
const AccountDataDisplaynameParamsSchema = {
type: 'object',
properties: {
userId: { type: 'string', pattern: MATRIX_USER_ID_PATTERN },
roomId: { type: 'string', pattern: MATRIX_ROOM_ID_PATTERN },
},
required: ['userId', 'roomId'],
};
const isAccountDataDisplaynameParamsProps = ajv.compile(AccountDataDisplaynameParamsSchema);
const AccountDataDisplaynameBodySchema = {
type: 'object',
properties: {
displayname: { type: 'string', nullable: true },
},
additionalProperties: true,
};
const isAccountDataDisplaynameBodyProps = ajv.compile(AccountDataDisplaynameBodySchema);
export const addUserRoutes = (router: ClientRouter) => {
router.put(
'/v3/user/:userId/rooms/:roomId/account_data/m.room.displayname',
{
params: isAccountDataDisplaynameParamsProps,
query: isImpersonationQueryProps,
body: isAccountDataDisplaynameBodyProps,
response: {
200: isEmptyObjectResponseProps,
401: isMatrixErrorProps,
403: isMatrixErrorProps,
404: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
const roomId = c.req.param('roomId') as RoomID;
const senderUsername = c.get('impersonatedUserId') as string;
const body = await c.req.json();
const user = await Users.findOneByUsername(senderUsername);
if (!user) {
return {
statusCode: 404,
body: {
errcode: 'M_NOT_FOUND',
error: 'User not found',
},
};
}
try {
await federationSDK.updateUserProfile(roomId, senderUsername, {
displayname: body.displayname ?? undefined,
});
return {
statusCode: 200,
body: {},
};
} catch (error) {
return internalError('Failed to update per-room displayname', error, { roomId, senderUsername });
}
},
);
};

View File

@@ -0,0 +1,38 @@
import { ajv } from '@rocket.chat/rest-typings';
import type { ClientRouter } from './_shared';
import { license, tags } from './_shared';
const VersionsResponseSchema = {
type: 'object',
properties: {
versions: {
type: 'array',
items: { type: 'string' },
},
unstable_features: {
type: 'object',
additionalProperties: { type: 'boolean' },
},
},
required: ['versions'],
};
const isVersionsResponseProps = ajv.compile(VersionsResponseSchema);
export const addVersionsRoutes = (router: ClientRouter) => {
router.get(
'/versions',
{
response: { 200: isVersionsResponseProps },
tags,
license,
},
async () => ({
body: {
versions: ['v1.4'],
},
statusCode: 200,
}),
);
};

View File

@@ -0,0 +1,144 @@
import { FederationMatrix } from '@rocket.chat/core-services';
import { federationSDK } from '@rocket.chat/federation-sdk';
import { Users } from '@rocket.chat/models';
import { getMatrixInviteRoutes } from './invite';
jest.mock('@rocket.chat/core-services', () => ({
FederationMatrix: {
canUserAccessFederation: jest.fn(),
},
}));
jest.mock('@rocket.chat/models', () => ({
Users: {
findOneByUsername: jest.fn(),
},
}));
jest.mock('@rocket.chat/federation-sdk', () => ({
federationSDK: {
verifyRequestSignature: jest.fn(),
getConfig: jest.fn(),
processInvite: jest.fn(),
},
NotAllowedError: class NotAllowedError extends Error {},
errCodes: {
M_UNAUTHORIZED: { errcode: 'M_UNAUTHORIZED', error: 'Unauthorized', status: 401 },
M_UNKNOWN: { errcode: 'M_UNKNOWN', error: 'Unknown error' },
},
}));
const mockVerifyRequestSignature = federationSDK.verifyRequestSignature as jest.MockedFunction<typeof federationSDK.verifyRequestSignature>;
const mockGetConfig = federationSDK.getConfig as jest.MockedFunction<typeof federationSDK.getConfig>;
const mockProcessInvite = federationSDK.processInvite as jest.MockedFunction<typeof federationSDK.processInvite>;
const mockFindOneByUsername = Users.findOneByUsername as jest.MockedFunction<typeof Users.findOneByUsername>;
const mockCanUserAccessFederation = FederationMatrix.canUserAccessFederation as jest.MockedFunction<
typeof FederationMatrix.canUserAccessFederation
>;
const OUR_SERVER_NAME = 'rocketchat.local';
const buildInviteEvent = (stateKey: string) => ({
type: 'm.room.member',
state_key: stateKey,
sender: '@attacker:attacker.com',
room_id: '!room:attacker.com',
origin_server_ts: 1600000000000,
depth: 1,
prev_events: [],
auth_events: [],
content: { membership: 'invite' },
});
const sendInvite = async (event: unknown) =>
getMatrixInviteRoutes()
.getHonoRouter()
.request('/v2/invite/!room:attacker.com/$event', {
method: 'PUT',
headers: {
'Authorization': 'X-Matrix origin="attacker.com"',
'Content-Type': 'application/json',
},
body: JSON.stringify({
room_version: '10',
event,
invite_room_state: [{ type: 'm.room.create', state_key: '', content: { creator: '@attacker:attacker.com' } }],
}),
});
describe('PUT /_matrix/federation/v2/invite/:roomId/:eventId', () => {
beforeEach(() => {
jest.clearAllMocks();
mockVerifyRequestSignature.mockResolvedValue({ origin: 'attacker.com' } as any);
mockGetConfig.mockImplementation((key) => (key === 'serverName' ? OUR_SERVER_NAME : undefined) as any);
mockFindOneByUsername.mockResolvedValue({ _id: 'victimId', username: 'victim' } as any);
mockCanUserAccessFederation.mockResolvedValue(true);
mockProcessInvite.mockImplementation(async (event: any) => ({ event }) as any);
});
it('should reject an invite whose state_key belongs to another homeserver', async () => {
const response = await sendInvite(buildInviteEvent(`@victim:attacker.com`));
expect(response.status).toBe(400);
expect(await response.json()).toEqual({
errcode: 'M_UNKNOWN',
error: 'The invite event must be for a user of this server',
});
expect(mockFindOneByUsername).not.toHaveBeenCalled();
expect(mockProcessInvite).not.toHaveBeenCalled();
});
it('should reject an invite whose state_key is not a valid user ID', async () => {
const response = await sendInvite(buildInviteEvent('victim'));
expect(response.status).toBe(400);
expect(await response.json()).toEqual({
errcode: 'M_UNKNOWN',
error: 'The invite event state_key is not a valid user ID',
});
expect(mockFindOneByUsername).not.toHaveBeenCalled();
expect(mockProcessInvite).not.toHaveBeenCalled();
});
it('should reject an invite whose state_key has an empty localpart', async () => {
const response = await sendInvite(buildInviteEvent(`@:${OUR_SERVER_NAME}`));
expect(response.status).toBe(400);
expect(await response.json()).toEqual({
errcode: 'M_UNKNOWN',
error: 'The invite event state_key is not a valid user ID',
});
expect(mockFindOneByUsername).not.toHaveBeenCalled();
expect(mockProcessInvite).not.toHaveBeenCalled();
});
it('should reject an invite addressed to a user that does not exist on this server', async () => {
mockFindOneByUsername.mockResolvedValue(null);
const response = await sendInvite(buildInviteEvent(`@ghost:${OUR_SERVER_NAME}`));
expect(response.status).toBe(403);
expect(await response.json()).toEqual({
errcode: 'M_FORBIDDEN',
error: 'User does not have permission to access federation',
});
expect(mockProcessInvite).not.toHaveBeenCalled();
});
it('should accept a state_key whose localpart contains characters the spec allows', async () => {
const response = await sendInvite(buildInviteEvent(`@victim+1/2:${OUR_SERVER_NAME}`));
expect(response.status).toBe(200);
expect(mockFindOneByUsername).toHaveBeenCalledWith('victim+1/2');
});
it('should process an invite addressed to a user of this server', async () => {
const response = await sendInvite(buildInviteEvent(`@victim:${OUR_SERVER_NAME}`));
expect(response.status).toBe(200);
expect(mockFindOneByUsername).toHaveBeenCalledWith('victim');
expect(mockProcessInvite).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,279 @@
import { FederationMatrix } from '@rocket.chat/core-services';
import { NotAllowedError, federationSDK } from '@rocket.chat/federation-sdk';
import { Router } from '@rocket.chat/http-router';
import { Users } from '@rocket.chat/models';
import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv';
import { getUsernameServername } from '../../helpers/getUsernameServername';
import { logger } from '../logger';
import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated';
const EventBaseSchema = {
type: 'object',
properties: {
type: {
type: 'string',
description: 'Event type',
},
content: {
type: 'object',
description: 'Event content',
},
sender: {
type: 'string',
},
room_id: {
type: 'string',
},
origin_server_ts: {
type: 'number',
},
depth: {
type: 'number',
},
prev_events: {
type: 'array',
items: {
type: 'string',
},
description: 'Previous events in the room',
},
auth_events: {
type: 'array',
items: {
type: 'string',
},
description: 'Authorization events',
},
origin: {
type: 'string',
description: 'Origin server',
},
hashes: {
type: 'object',
nullable: true,
},
signatures: {
type: 'object',
nullable: true,
},
unsigned: {
type: 'object',
description: 'Unsigned data',
nullable: true,
},
},
required: ['type', 'content', 'sender', 'room_id', 'origin_server_ts', 'depth', 'prev_events', 'auth_events'],
};
const MembershipEventContentSchema = {
type: 'object',
properties: {
membership: {
type: 'string',
},
displayname: {
type: 'string',
nullable: true,
},
avatar_url: {
type: 'string',
nullable: true,
},
},
required: ['membership'],
};
const RoomMemberEventSchema = {
type: 'object',
allOf: [
EventBaseSchema,
{
type: 'object',
properties: {
type: {
type: 'string',
const: 'm.room.member',
},
content: MembershipEventContentSchema,
state_key: {
type: 'string',
},
},
required: ['type', 'content', 'state_key'],
},
],
};
const ProcessInviteParamsSchema = {
type: 'object',
properties: {
roomId: {
type: 'string',
},
eventId: {
type: 'string',
},
},
required: ['roomId', 'eventId'],
};
const isProcessInviteParamsProps = ajv.compile(ProcessInviteParamsSchema);
const ProcessInviteResponseSchema = {
type: 'object',
properties: {
event: RoomMemberEventSchema,
},
required: ['event'],
};
const isProcessInviteResponseProps = ajv.compile(ProcessInviteResponseSchema);
export const getMatrixInviteRoutes = () => {
// PUT /_matrix/federation/v2/invite/{roomId}/{eventId}
// https://spec.matrix.org/v1.19/server-server-api/#put_matrixfederationv2inviteroomideventid
return new Router('/federation').put(
'/v2/invite/:roomId/:eventId',
{
// TODO: add schema from room package. `event` is a PDU whose format varies by room
// version, so it stays unconstrained here; room_version and event are required per spec.
body: ajv.compile({
type: 'object',
properties: {
room_version: { type: 'string' },
event: { type: 'object' },
invite_room_state: {
type: 'array',
items: { type: 'object' },
nullable: true,
},
},
required: ['room_version', 'event'],
}),
params: isProcessInviteParamsProps,
response: {
200: isProcessInviteResponseProps,
},
tags: ['Federation'],
license: ['federation'],
},
isAuthenticatedMiddleware(),
async (c) => {
const { roomId, eventId } = c.req.param();
const { event, room_version: roomVersion, invite_room_state: strippedStateEvents } = await c.req.json();
const userToCheck = event.state_key;
// matches Synapse: the PDU itself stays unvalidated, but an event that is not an invite
// membership event cannot be processed, so reject it instead of failing later
if (typeof userToCheck !== 'string' || !userToCheck || event.type !== 'm.room.member' || event.content?.membership !== 'invite') {
return {
body: {
errcode: 'M_UNKNOWN',
error: 'The event was not an m.room.member invite event',
},
statusCode: 400,
};
}
// spec: servers SHOULD return M_INVALID_PARAM if m.room.create is missing from invite_room_state
if (!strippedStateEvents?.some((e: any) => e.type === 'm.room.create')) {
return {
body: {
errcode: 'M_INVALID_PARAM',
error: 'Missing invite_room_state: m.room.create event is required',
},
statusCode: 400,
};
}
// spec grammar is `@localpart:server_name`, where localpart is non-empty. deliberately
// not `validateFederatedUsername`, which is stricter than the spec and would reject
// legal localparts containing `/` or `+`
if (!/^@[A-Za-z0-9_=/.+-]+:.+$/.test(userToCheck)) {
return {
body: {
errcode: 'M_UNKNOWN',
error: 'The invite event state_key is not a valid user ID',
},
statusCode: 400,
};
}
// an invite addressed to a user of another homeserver would create a local subscription
// that can never be accepted, since the remote server never invited our copy of that user
const [username, , isLocalUser] = getUsernameServername(userToCheck, federationSDK.getConfig('serverName'));
if (!isLocalUser) {
return {
body: {
errcode: 'M_UNKNOWN',
error: 'The invite event must be for a user of this server',
},
statusCode: 400,
};
}
const ourUser = await Users.findOneByUsername(username);
// same response as the federation permission check below, so an unauthorized remote
// server cannot use the invite endpoint to probe which local users exist
if (!ourUser) {
logger.info({ msg: 'Invite for unknown local user, rejecting invite to room', userId: userToCheck, roomId });
return {
body: {
errcode: 'M_FORBIDDEN',
error: 'User does not have permission to access federation',
},
statusCode: 403,
};
}
// check federation permission before processing the invite
if (!(await FederationMatrix.canUserAccessFederation(ourUser))) {
logger.info({ msg: 'User denied federation access, rejecting invite to room', userId: userToCheck, roomId });
return {
body: {
errcode: 'M_FORBIDDEN',
error: 'User does not have permission to access federation',
},
statusCode: 403,
};
}
try {
const inviteEvent = await federationSDK.processInvite(event, eventId, roomVersion, strippedStateEvents);
return {
body: {
event: inviteEvent.event,
},
statusCode: 200,
};
} catch (error) {
if (error instanceof NotAllowedError) {
return {
body: {
errcode: 'M_FORBIDDEN',
error: 'This server does not allow joining this type of room based on federation settings.',
},
statusCode: 403,
};
}
logger.error({ msg: 'Error processing invite', err: error });
return {
body: {
errcode: 'M_UNKNOWN',
error: error instanceof Error ? error.message : 'Internal server error while processing request',
},
statusCode: 500,
};
}
},
);
};

View File

@@ -0,0 +1,56 @@
import { federationSDK } from '@rocket.chat/federation-sdk';
import { Router } from '@rocket.chat/http-router';
import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv';
const ServerKeyResponseSchema = {
type: 'object',
properties: {
old_verify_keys: {
type: 'object',
description: 'Old verification keys',
},
server_name: {
type: 'string',
description: 'Matrix server name',
},
signatures: {
type: 'object',
description: 'Server signatures',
},
valid_until_ts: {
type: 'number',
minimum: 0,
description: 'Unix timestamp in milliseconds',
},
verify_keys: {
type: 'object',
description: 'Current verification keys',
},
},
required: ['old_verify_keys', 'server_name', 'signatures', 'valid_until_ts', 'verify_keys'],
};
const isServerKeyResponseProps = ajv.compile(ServerKeyResponseSchema);
export const getKeyServerRoutes = () => {
// GET /_matrix/key/v2/server
// https://spec.matrix.org/v1.19/server-server-api/#get_matrixkeyv2server
return new Router('/key').get(
'/v2/server',
{
response: {
200: isServerKeyResponseProps,
},
tags: ['Key'],
license: ['federation'],
},
async () => {
const response = await federationSDK.getSignedServerKey();
return {
body: response,
statusCode: 200,
};
},
);
};

View File

@@ -0,0 +1,106 @@
import { NotAllowedError, federationSDK } from '@rocket.chat/federation-sdk';
import { Router } from '@rocket.chat/http-router';
import { ajv } from '@rocket.chat/rest-typings';
import { logger } from '../logger';
import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated';
const isMakeLeaveParamsProps = ajv.compile({
type: 'object',
properties: { roomId: { type: 'string' }, userId: { type: 'string' } },
required: ['roomId', 'userId'],
});
const isMakeLeaveSuccessResponseProps = ajv.compile({
type: 'object',
properties: {
event: {
type: 'object',
properties: {
content: {
type: 'object',
properties: {
membership: {
type: 'string',
const: 'leave',
},
},
},
origin: {
type: 'string',
},
origin_server_ts: {
type: 'number',
},
sender: {
type: 'string',
},
state_key: {
type: 'string',
},
type: {
type: 'string',
const: 'm.room.member',
},
},
},
room_version: { type: 'string' },
},
});
const isMakeLeaveForbiddenResponseProps = ajv.compile({
type: 'object',
properties: { errcode: { type: 'string', const: 'M_FORBIDDEN' }, error: { type: 'string' } },
});
const isMakeLeaveErrorResponseProps = ajv.compile({
type: 'object',
properties: { errcode: { type: 'string', const: 'M_UNKNOWN' }, error: { type: 'string' } },
});
export const getMatrixMakeLeaveRoutes = () => {
// GET /_matrix/federation/v1/make_leave/{roomId}/{userId}
// https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1make_leaveroomiduserid
return new Router('/federation').get(
'/v1/make_leave/:roomId/:userId',
{
params: isMakeLeaveParamsProps,
response: {
200: isMakeLeaveSuccessResponseProps,
403: isMakeLeaveForbiddenResponseProps,
500: isMakeLeaveErrorResponseProps,
},
tags: ['Federation'],
license: ['federation'],
},
isAuthenticatedMiddleware(),
async (c) => {
const { roomId, userId } = c.req.param();
try {
// TODO: Remove out of spec attributes being returned
const makeLeaveResponse = await federationSDK.makeLeave(roomId, userId);
return {
body: makeLeaveResponse,
statusCode: 200,
};
} catch (error) {
if (error instanceof NotAllowedError) {
return {
body: {
errcode: 'M_FORBIDDEN',
error: 'This server does not allow leaving this room based on federation settings.',
},
statusCode: 403,
};
}
logger.error({ msg: 'Error making leave', err: error });
return {
body: {
errcode: 'M_UNKNOWN',
error: error instanceof Error ? error.message : 'Internal server error while processing request',
},
statusCode: 500,
};
}
},
);
};

View File

@@ -0,0 +1,178 @@
import { Router } from '@rocket.chat/http-router';
import { Users } from '@rocket.chat/models';
import { ajv, ajvQuery } from '@rocket.chat/rest-typings';
import { MatrixMediaService } from '../../services/MatrixMediaService';
import { isAppServiceAuthenticatedMiddleware } from '../middlewares/isAppServiceAuthenticated';
const MatrixErrorSchema = {
type: 'object',
properties: {
errcode: { type: 'string' },
error: { type: 'string' },
},
required: ['errcode', 'error'],
};
const isMatrixErrorProps = ajv.compile(MatrixErrorSchema);
const UploadResponseSchema = {
type: 'object',
properties: {
content_uri: { type: 'string' },
},
required: ['content_uri'],
};
const isUploadResponseProps = ajv.compile(UploadResponseSchema);
const UploadQuerySchema = {
type: 'object',
properties: {
filename: { type: 'string' },
user_id: { type: 'string' },
access_token: { type: 'string' },
},
};
const isUploadQueryProps = ajvQuery.compile<{
filename?: string;
user_id?: string;
access_token?: string;
}>(UploadQuerySchema);
const ConfigResponseSchema = {
type: 'object',
properties: {
'm.upload.size': { type: 'number' },
},
additionalProperties: true,
};
const isConfigResponseProps = ajv.compile(ConfigResponseSchema);
const tags = ['Federation', 'Media'];
const license: ['federation'] = ['federation'];
// Advertised to bridges as m.upload.size in the /r0/config route below.
const MAX_UPLOAD_SIZE = 50 * 1024 * 1024;
export const getMatrixMediaBridgeRoutes = () => {
return (
new Router('/media')
// POST /_matrix/media/v3/upload
.post(
'/v3/upload',
{
query: isUploadQueryProps,
response: {
200: isUploadResponseProps,
400: isMatrixErrorProps,
401: isMatrixErrorProps,
413: isMatrixErrorProps,
500: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async (c) => {
try {
const senderUsername = c.get('impersonatedUserId') as string;
const fileName = c.req.query('filename') || `upload-${Date.now()}`;
const mimeType = c.req.header('content-type') || 'application/octet-stream';
const user = await Users.findOneByUsername(senderUsername, { projection: { _id: 1 } });
if (!user) {
return {
statusCode: 401,
body: {
errcode: 'M_UNKNOWN_TOKEN',
error: 'Impersonated user not found',
},
};
}
// Reject on the declared size before buffering; the byteLength check below
// still catches bodies without (or lying about) content-length.
const contentLength = Number(c.req.header('content-length'));
if (contentLength > MAX_UPLOAD_SIZE) {
return {
statusCode: 413,
body: {
errcode: 'M_TOO_LARGE',
error: 'Upload exceeds the maximum allowed size',
},
};
}
const arrayBuffer = await c.req.raw.arrayBuffer();
if (!arrayBuffer.byteLength) {
return {
statusCode: 400,
body: {
errcode: 'M_BAD_REQUEST',
error: 'Empty upload body',
},
};
}
if (arrayBuffer.byteLength > MAX_UPLOAD_SIZE) {
return {
statusCode: 413,
body: {
errcode: 'M_TOO_LARGE',
error: 'Upload exceeds the maximum allowed size',
},
};
}
const buffer = Buffer.from(arrayBuffer);
const { mxcUri } = await MatrixMediaService.uploadFromAppService({
buffer,
fileName,
mimeType,
userId: user._id,
});
return {
statusCode: 200,
body: { content_uri: mxcUri },
};
} catch (error) {
return {
statusCode: 500,
body: {
errcode: 'M_UNKNOWN',
error: 'Failed to upload media',
},
};
}
},
)
// GET /_matrix/media/r0/config (literal r0; matrix-bot-sdk hardcodes this path)
.get(
'/r0/config',
{
response: {
200: isConfigResponseProps,
401: isMatrixErrorProps,
},
tags,
license,
},
isAppServiceAuthenticatedMiddleware(),
async () => {
return {
statusCode: 200,
body: {
'm.upload.size': MAX_UPLOAD_SIZE,
},
};
},
)
);
};

View File

@@ -0,0 +1,155 @@
import crypto from 'node:crypto';
import type { IUpload } from '@rocket.chat/core-typings';
import { federationSDK } from '@rocket.chat/federation-sdk';
import { Router } from '@rocket.chat/http-router';
import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv';
import { MatrixMediaService } from '../../services/MatrixMediaService';
import { canAccessResourceMiddleware } from '../middlewares/canAccessResource';
const MediaDownloadParamsSchema = {
type: 'object',
properties: {
mediaId: { type: 'string' },
},
required: ['mediaId'],
additionalProperties: false,
};
const ErrorResponseSchema = {
type: 'object',
properties: {
errcode: { type: 'string' },
error: { type: 'string' },
},
required: ['errcode', 'error'],
};
const BufferResponseSchema = {
type: 'object',
description: 'Raw file buffer or multipart response',
};
const isMediaDownloadParamsProps = ajv.compile(MediaDownloadParamsSchema);
const isErrorResponseProps = ajv.compile(ErrorResponseSchema);
const isBufferResponseProps = ajv.compile(BufferResponseSchema);
const SECURITY_HEADERS = {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Content-Security-Policy': "default-src 'none'; img-src 'self'; media-src 'self'",
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
};
function createMultipartResponse(
buffer: Buffer,
mimeType: string,
fileName: string,
metadata: Record<string, any> = {},
): { body: Buffer; contentType: string } {
const boundary = crypto.randomBytes(16).toString('hex');
const parts: string[] = [];
parts.push(`--${boundary}`, 'Content-Type: application/json', '', JSON.stringify(metadata));
parts.push(`--${boundary}`, `Content-Type: ${mimeType}`, `Content-Disposition: attachment; filename="${fileName}"`, '');
const headerBuffer = Buffer.from(`${parts.join('\r\n')}\r\n`);
const endBoundary = Buffer.from(`\r\n--${boundary}--\r\n`);
return {
body: Buffer.concat([headerBuffer, buffer, endBoundary]),
contentType: `multipart/mixed; boundary=${boundary}`,
};
}
async function getMediaFile(mediaId: string, serverName: string): Promise<{ file: IUpload; buffer: Buffer } | null> {
const file = await MatrixMediaService.getLocalFileForMatrixNode(mediaId, serverName);
if (!file) {
return null;
}
const buffer = await MatrixMediaService.getLocalFileBuffer(file);
return { file, buffer };
}
export const getMatrixMediaRoutes = () => {
return (
new Router('/federation')
// GET /_matrix/federation/v1/media/download/{mediaId}
// https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1mediadownloadmediaid
.get(
'/v1/media/download/:mediaId',
{
params: isMediaDownloadParamsProps,
response: {
200: isBufferResponseProps,
401: isErrorResponseProps,
403: isErrorResponseProps,
404: isErrorResponseProps,
429: isErrorResponseProps,
500: isErrorResponseProps,
},
tags: ['Federation', 'Media'],
},
canAccessResourceMiddleware('media'),
async (c) => {
try {
const { mediaId } = c.req.param();
const serverName = federationSDK.getConfig('serverName');
// TODO: Add file streaming support
const result = await getMediaFile(mediaId, serverName);
if (!result) {
return {
statusCode: 404,
body: { errcode: 'M_NOT_FOUND', error: 'Media not found' },
};
}
const { file, buffer } = result;
const mimeType = file.type || 'application/octet-stream';
const fileName = file.name || mediaId;
const multipartResponse = createMultipartResponse(buffer, mimeType, fileName);
return {
statusCode: 200,
headers: {
...SECURITY_HEADERS,
'content-type': multipartResponse.contentType,
'content-length': String(multipartResponse.body.length),
},
body: multipartResponse.body,
};
} catch (error) {
return {
statusCode: 500,
body: { errcode: 'M_UNKNOWN', error: 'Internal server error' },
};
}
},
)
// GET /_matrix/federation/v1/media/thumbnail/{mediaId}
// https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1mediathumbnailmediaid
.get(
'/v1/media/thumbnail/:mediaId',
{
params: isMediaDownloadParamsProps,
response: {
404: isErrorResponseProps,
},
tags: ['Federation', 'Media'],
},
canAccessResourceMiddleware('media'),
async (_c) => ({
statusCode: 404,
body: {
errcode: 'M_UNRECOGNIZED',
error: 'This endpoint is not implemented on the homeserver side',
},
}),
)
);
};

View File

@@ -0,0 +1,583 @@
import { eventIdSchema, roomIdSchema, userIdSchema, federationSDK, type RoomVersion } from '@rocket.chat/federation-sdk';
import { Router } from '@rocket.chat/http-router';
import { ajv, ajvQuery } from '@rocket.chat/rest-typings';
import { canAccessResourceMiddleware } from '../middlewares/canAccessResource';
import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated';
const UsernameSchema = {
type: 'string',
pattern: '^@[A-Za-z0-9_=\\/.+-]+:(.+)$',
description: 'Matrix user ID in format @user:server.com',
};
const RoomIdSchema = {
type: 'string',
pattern: '^![A-Za-z0-9_=\\/.+-]+:(.+)$',
description: 'Matrix room ID in format !room:server.com',
};
const TimestampSchema = {
type: 'number',
minimum: 0,
description: 'Unix timestamp in milliseconds',
};
const ServerNameSchema = {
type: 'string',
description: 'Matrix server name',
};
const QueryProfileQuerySchema = {
type: 'object',
properties: {
user_id: UsernameSchema,
field: {
// open string, not an enum: the spec defines displayname, avatar_url and m.tz,
// and servers MAY allow arbitrary additional profile fields
type: 'string',
description: 'Profile field to query',
nullable: true,
},
},
required: ['user_id'],
};
const isQueryProfileQueryProps = ajvQuery.compile(QueryProfileQuerySchema);
const QueryProfileResponseSchema = {
type: 'object',
properties: {
displayname: {
type: 'string',
description: 'User display name',
nullable: true,
},
avatar_url: {
type: 'string',
description: 'User avatar URL',
nullable: true,
},
},
};
const isQueryProfileResponseProps = ajv.compile(QueryProfileResponseSchema);
const QueryKeysBodySchema = {
type: 'object',
properties: {
device_keys: {
type: 'object',
description: 'Device keys to query',
},
},
required: ['device_keys'],
};
const isQueryKeysBodyProps = ajv.compile(QueryKeysBodySchema);
const QueryKeysResponseSchema = {
type: 'object',
properties: {
device_keys: {
type: 'object',
description: 'Device keys for the requested users',
},
},
required: ['device_keys'],
};
const isQueryKeysResponseProps = ajv.compile(QueryKeysResponseSchema);
const GetDevicesParamsSchema = {
type: 'object',
properties: {
userId: UsernameSchema,
},
required: ['userId'],
additionalProperties: false,
};
const isGetDevicesParamsProps = ajv.compile(GetDevicesParamsSchema);
const GetDevicesResponseSchema = {
type: 'object',
properties: {
user_id: UsernameSchema,
stream_id: {
type: 'number',
description: 'Device list stream ID',
},
devices: {
type: 'array',
items: {
type: 'object',
properties: {
device_id: {
type: 'string',
description: 'Device ID',
},
display_name: {
type: 'string',
description: 'Device display name',
nullable: true,
},
last_seen_ip: {
type: 'string',
description: 'Last seen IP address',
nullable: true,
},
last_seen_ts: {
...TimestampSchema,
nullable: true,
},
},
required: ['device_id'],
},
description: 'List of devices for the user',
},
},
required: ['user_id', 'stream_id', 'devices'],
};
const isGetDevicesResponseProps = ajv.compile(GetDevicesResponseSchema);
const MakeJoinParamsSchema = {
type: 'object',
properties: {
roomId: RoomIdSchema,
userId: UsernameSchema,
},
required: ['roomId', 'userId'],
};
const isMakeJoinParamsProps = ajv.compile(MakeJoinParamsSchema);
const MakeJoinQuerySchema = {
type: 'object',
properties: {
ver: {
// a string branch here would be redundant: ajvQuery coerces a single `?ver=` into a
// one-element array, and in a `oneOf` both branches would match and fail validation
type: 'array',
items: {
type: 'string',
},
description: 'Room versions supported by the sending server',
},
},
};
const isMakeJoinQueryProps = ajvQuery.compile(MakeJoinQuerySchema);
const MakeJoinResponseSchema = {
type: 'object',
properties: {
room_version: {
type: 'string',
description: 'Room version',
},
event: {
type: 'object',
properties: {
content: {
type: 'object',
properties: {
membership: {
type: 'string',
const: 'join',
},
join_authorised_via_users_server: {
type: 'string',
nullable: true,
},
},
required: ['membership'],
},
room_id: RoomIdSchema,
sender: UsernameSchema,
state_key: UsernameSchema,
type: {
type: 'string',
const: 'm.room.member',
},
origin_server_ts: TimestampSchema,
origin: ServerNameSchema,
depth: {
type: 'number',
description: 'Depth of the event in the DAG',
nullable: true,
},
prev_events: {
type: 'array',
items: {
type: 'string',
},
description: 'Previous events in the room',
nullable: true,
},
auth_events: {
type: 'array',
items: {
type: 'string',
},
description: 'Authorization events',
nullable: true,
},
hashes: {
type: 'object',
properties: {
sha256: {
type: 'string',
description: 'SHA256 hash of the event',
},
},
required: ['sha256'],
nullable: true,
},
signatures: {
type: 'object',
description: 'Event signatures by server and key ID',
nullable: true,
},
unsigned: {
type: 'object',
description: 'Unsigned data',
nullable: true,
},
},
required: ['content', 'room_id', 'sender', 'state_key', 'type', 'origin_server_ts', 'origin'],
},
},
required: ['room_version', 'event'],
};
const isMakeJoinResponseProps = ajv.compile(MakeJoinResponseSchema);
const MakeJoinIncompatibleVersionResponseSchema = {
type: 'object',
properties: {
errcode: {
type: 'string',
const: 'M_INCOMPATIBLE_ROOM_VERSION',
},
error: {
type: 'string',
},
room_version: {
type: 'string',
description: 'The version of the room',
},
},
required: ['errcode', 'error', 'room_version'],
};
const isMakeJoinIncompatibleVersionResponseProps = ajv.compile(MakeJoinIncompatibleVersionResponseSchema);
const GetMissingEventsParamsSchema = {
type: 'object',
properties: {
roomId: RoomIdSchema,
},
required: ['roomId'],
};
const isGetMissingEventsParamsProps = ajv.compile(GetMissingEventsParamsSchema);
const GetMissingEventsBodySchema = {
type: 'object',
properties: {
earliest_events: {
type: 'array',
items: {
type: 'string',
},
description: 'Earliest events',
},
latest_events: {
type: 'array',
items: {
type: 'string',
},
description: 'Latest events',
},
limit: {
// optional per spec (defaults to 10) and unbounded; the handler applies the default and a cap
type: 'integer',
description: 'Maximum number of events to return',
nullable: true,
},
min_depth: {
type: 'integer',
description: 'Minimum depth of events to retrieve',
nullable: true,
},
},
required: ['earliest_events', 'latest_events'],
};
const isGetMissingEventsBodyProps = ajv.compile(GetMissingEventsBodySchema);
const GetMissingEventsResponseSchema = {
type: 'object',
properties: {
events: {
type: 'array',
items: {
type: 'object',
},
description: 'Missing events',
},
},
required: ['events'],
};
const isGetMissingEventsResponseProps = ajv.compile(GetMissingEventsResponseSchema);
const EventAuthParamsSchema = {
type: 'object',
properties: {
roomId: RoomIdSchema,
eventId: {
type: 'string',
description: 'Event ID',
},
},
required: ['roomId', 'eventId'],
};
const isEventAuthParamsProps = ajv.compile(EventAuthParamsSchema);
const EventAuthResponseSchema = {
type: 'object',
properties: {
auth_chain: {
type: 'array',
items: {
type: 'object',
},
description: 'Authorization chain for the event',
},
},
required: ['auth_chain'],
};
const isEventAuthResponseProps = ajv.compile(EventAuthResponseSchema);
export const getMatrixProfilesRoutes = () => {
return (
new Router('/federation')
.use(isAuthenticatedMiddleware())
// GET /_matrix/federation/v1/query/profile
// https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1queryprofile
.get(
'/v1/query/profile',
{
query: isQueryProfileQueryProps,
response: {
200: isQueryProfileResponseProps,
},
tags: ['Federation'],
license: ['federation'],
},
async (c) => {
const { user_id: userId, field } = c.req.query();
const response = await federationSDK.queryProfile(userId);
if (!response) {
return {
body: {
errcode: 'M_NOT_FOUND',
error: `User ${userId} not found`,
},
statusCode: 404,
};
}
if (field) {
return {
body: {
[field]: response[field as 'displayname' | 'avatar_url'] || null,
},
statusCode: 200,
};
}
return {
body: {
displayname: response.displayname,
avatar_url: response.avatar_url,
},
statusCode: 200,
};
},
)
// POST /_matrix/federation/v1/user/keys/query
// https://spec.matrix.org/v1.19/server-server-api/#post_matrixfederationv1userkeysquery
.post(
'/v1/user/keys/query',
{
body: isQueryKeysBodyProps,
response: {
200: isQueryKeysResponseProps,
},
tags: ['Federation'],
license: ['federation'],
},
async (c) => {
const body = await c.req.json();
const response = await federationSDK.queryKeys(body.device_keys);
return {
body: response,
statusCode: 200,
};
},
)
// GET /_matrix/federation/v1/user/devices/{userId}
// https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1userdevicesuserid
.get(
'/v1/user/devices/:userId',
{
params: isGetDevicesParamsProps,
response: {
200: isGetDevicesResponseProps,
},
tags: ['Federation'],
license: ['federation'],
},
async (c) => {
return {
body: {
devices: [],
stream_id: 0,
user_id: c.req.param('userId'),
},
statusCode: 200,
};
},
)
// GET /_matrix/federation/v1/make_join/{roomId}/{userId}
// https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1make_joinroomiduserid
.get(
'/v1/make_join/:roomId/:userId',
{
params: isMakeJoinParamsProps,
query: isMakeJoinQueryProps,
response: {
200: isMakeJoinResponseProps,
400: isMakeJoinIncompatibleVersionResponseProps,
},
tags: ['Federation'],
license: ['federation'],
},
canAccessResourceMiddleware('room'),
async (c) => {
const { roomId, userId } = c.req.param();
const url = new URL(c.req.url);
const verParams = url.searchParams.getAll('ver');
try {
const response = await federationSDK.makeJoin(
roomIdSchema.parse(roomId),
userIdSchema.parse(userId),
// spec: "The room versions the sending server has support for. Defaults to [1]."
verParams.length > 0 ? (verParams as RoomVersion[]) : ['1'],
);
return {
body: {
room_version: response.room_version,
event: response.event,
},
statusCode: 200,
};
} catch (error) {
// the SDK throws when the room's version is not in the requested `ver` list
const incompatibleVersion = error instanceof Error && error.message.match(/^Unsupported room version: (.+)$/);
if (incompatibleVersion) {
return {
body: {
errcode: 'M_INCOMPATIBLE_ROOM_VERSION',
error: 'Your homeserver does not support the features required to join this room',
room_version: incompatibleVersion[1],
},
statusCode: 400,
};
}
throw error;
}
},
)
// POST /_matrix/federation/v1/get_missing_events/{roomId}
// https://spec.matrix.org/v1.19/server-server-api/#post_matrixfederationv1get_missing_eventsroomid
.post(
'/v1/get_missing_events/:roomId',
{
params: isGetMissingEventsParamsProps,
body: isGetMissingEventsBodyProps,
response: {
200: isGetMissingEventsResponseProps,
},
tags: ['Federation'],
license: ['federation'],
},
canAccessResourceMiddleware('room'),
async (c) => {
const { roomId } = c.req.param();
const body = await c.req.json();
const limit = Math.min(body.limit ?? 10, 100);
const minDepth = body.min_depth ?? 0;
// this will be handled by the federation-sdk on next versions so this can be removed
if (limit < 1) {
return {
body: { events: [] },
statusCode: 200,
};
}
const response = await federationSDK.getMissingEvents(
roomIdSchema.parse(roomId),
body.earliest_events,
body.latest_events,
limit,
minDepth,
);
return {
body: response,
statusCode: 200,
};
},
)
// GET /_matrix/federation/v1/event_auth/{roomId}/{eventId}
// https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1event_authroomideventid
.get(
'/v1/event_auth/:roomId/:eventId',
{
params: isEventAuthParamsProps,
response: {
200: isEventAuthResponseProps,
},
tags: ['Federation'],
license: ['federation'],
},
canAccessResourceMiddleware('room'),
async (c) => {
const { roomId, eventId } = c.req.param();
const response = await federationSDK.eventAuth(roomIdSchema.parse(roomId), eventIdSchema.parse(eventId));
return {
body: response,
statusCode: 200,
};
},
)
);
};

View File

@@ -0,0 +1,236 @@
import { federationSDK } from '@rocket.chat/federation-sdk';
import { Router } from '@rocket.chat/http-router';
import { ajv, ajvQuery } from '@rocket.chat/rest-typings';
import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated';
// All query params are optional per spec.
const PublicRoomsQuerySchema = {
type: 'object',
properties: {
limit: {
// spec default is 0, meaning no limit; negatives are meaningless and Synapse rejects them
type: 'integer',
minimum: 0,
description: 'Maximum number of rooms to return',
},
since: {
type: 'string',
description: 'Pagination token from a previous call',
},
include_all_networks: {
type: 'boolean',
description: 'Include all networks (ignored)',
},
third_party_instance_id: {
type: 'string',
description: 'Specific third-party network to request (ignored)',
},
},
};
const isPublicRoomsQueryProps = ajvQuery.compile(PublicRoomsQuerySchema);
const RoomObjectSchema = {
type: 'object',
properties: {
avatar_url: {
type: 'string',
description: 'Room avatar URL',
},
canonical_alias: {
type: 'string',
description: 'Room canonical alias',
nullable: true,
},
guest_can_join: {
type: 'boolean',
description: 'Whether guests can join the room',
},
join_rule: {
type: 'string',
description: 'Room join rule',
},
name: {
type: 'string',
description: 'Room name',
},
num_joined_members: {
type: 'number',
description: 'Number of joined members',
nullable: true,
},
room_id: {
type: 'string',
description: 'Room ID',
},
room_type: {
type: 'string',
description: 'Room type',
nullable: true,
},
topic: {
type: 'string',
description: 'Room topic',
nullable: true,
},
world_readable: {
type: 'boolean',
description: 'Whether the room is world readable',
},
},
required: ['avatar_url', 'guest_can_join', 'join_rule', 'name', 'room_id', 'world_readable'],
};
const PublicRoomsResponseSchema = {
type: 'object',
properties: {
chunk: {
type: 'array',
items: RoomObjectSchema,
description: 'Array of public rooms',
},
},
required: ['chunk'],
};
const isPublicRoomsResponseProps = ajv.compile(PublicRoomsResponseSchema);
// All body fields are optional per spec: "Options for which rooms to return, or empty object to use defaults."
const PublicRoomsPostBodySchema = {
type: 'object',
properties: {
limit: {
type: 'integer',
minimum: 0,
description: 'Maximum number of rooms to return',
nullable: true,
},
since: {
type: 'string',
description: 'Pagination token from a previous request',
nullable: true,
},
include_all_networks: {
type: 'boolean',
description: 'Include all networks (ignored)',
nullable: true,
},
third_party_instance_id: {
type: 'string',
description: 'Specific third-party network to request (ignored)',
nullable: true,
},
filter: {
type: 'object',
properties: {
generic_search_term: {
type: 'string',
description: 'Generic search term for filtering rooms',
nullable: true,
},
room_types: {
type: 'array',
items: {
type: ['string', 'null'],
},
description: 'Array of room types to filter by',
nullable: true,
},
},
nullable: true,
},
},
};
const isPublicRoomsPostBodyProps = ajv.compile(PublicRoomsPostBodySchema);
export const getMatrixRoomsRoutes = () => {
return (
new Router('/federation')
.use(isAuthenticatedMiddleware())
// GET /_matrix/federation/v1/publicRooms
// https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1publicrooms
.get(
'/v1/publicRooms',
{
query: isPublicRoomsQueryProps,
response: {
200: isPublicRoomsResponseProps,
},
tags: ['Federation'],
license: ['federation'],
},
async () => {
const defaultObj = {
join_rule: 'public',
guest_can_join: false, // trying to reduce required endpoint hits
world_readable: false, // ^^^
avatar_url: '', // ?? don't have any yet
};
const publicRooms = await federationSDK.getAllPublicRoomIdsAndNames();
return {
body: {
chunk: publicRooms.map((room) => ({
...defaultObj,
...room,
})),
},
statusCode: 200,
};
},
)
// POST /_matrix/federation/v1/publicRooms
// https://spec.matrix.org/v1.19/server-server-api/#post_matrixfederationv1publicrooms
.post(
'/v1/publicRooms',
{
body: isPublicRoomsPostBodyProps,
response: {
200: isPublicRoomsResponseProps,
},
tags: ['Federation'],
license: ['federation'],
},
async (c) => {
const body = await c.req.json();
const defaultObj = {
join_rule: 'public',
guest_can_join: false, // trying to reduce required endpoint hits
world_readable: false, // ^^^
avatar_url: '', // ?? don't have any yet
};
const { filter } = body;
const publicRooms = await federationSDK.getAllPublicRoomIdsAndNames();
return {
body: {
chunk: publicRooms
.filter((r) => {
if (filter?.generic_search_term) {
return r.name.toLowerCase().includes(filter.generic_search_term.toLowerCase());
}
// Today only one room type is supported (https://spec.matrix.org/v1.15/client-server-api/#types)
// TODO: https://rocketchat.atlassian.net/browse/FDR-152 -> Implement logic to handle custom room types
// if (filter.room_types) {
// }
return true;
})
.map((room) => ({
...defaultObj,
...room,
})),
},
statusCode: 200,
};
},
)
);
};

View File

@@ -0,0 +1,146 @@
import { federationSDK } from '@rocket.chat/federation-sdk';
import { Router } from '@rocket.chat/http-router';
import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv';
import { canAccessResourceMiddleware } from '../middlewares/canAccessResource';
const UsernameSchema = {
type: 'string',
pattern: '^@[A-Za-z0-9_=\\/.+-]+:(.+)$',
description: 'Matrix user ID in format @user:server.com',
};
const RoomIdSchema = {
type: 'string',
pattern: '^![A-Za-z0-9_=\\/.+-]+:(.+)$',
description: 'Matrix room ID in format !room:server.com',
};
const EventIdSchema = {
type: 'string',
pattern: '^\\$[A-Za-z0-9_=\\/.+-]+(:(.+))?$',
description: 'Matrix event ID in format $event',
};
const TimestampSchema = {
type: 'number',
minimum: 0,
description: 'Unix timestamp in milliseconds',
};
const ServerNameSchema = {
type: 'string',
description: 'Matrix server name',
};
const SendJoinParamsSchema = {
type: 'object',
properties: {
roomId: RoomIdSchema,
stateKey: EventIdSchema,
},
required: ['roomId', 'stateKey'],
};
const isSendJoinParamsProps = ajv.compile(SendJoinParamsSchema);
const SendJoinEventSchema = {
type: 'object',
properties: {
type: {
type: 'string',
const: 'm.room.member',
},
state_key: {
...UsernameSchema,
description: 'Matrix user ID of the joining member',
},
sender: {
...UsernameSchema,
description: 'Matrix user ID of the joining member',
},
origin: {
...ServerNameSchema,
description: 'The name of the joining homeserver',
},
origin_server_ts: TimestampSchema,
content: {
type: 'object',
properties: {
membership: {
type: 'string',
const: 'join',
},
join_authorised_via_users_server: {
...UsernameSchema,
description: 'User ID of a resident server member authorizing the join into a restricted room',
},
},
required: ['membership'],
},
},
required: ['type', 'state_key', 'sender', 'origin', 'origin_server_ts', 'content'],
};
const isSendJoinEventProps = ajv.compile(SendJoinEventSchema);
const SendJoinResponseSchema = {
type: 'object',
properties: {
event: {
type: 'object',
description: 'The processed join event',
},
state: {
type: 'array',
items: {
type: 'object',
},
description: 'Current state events in the room',
},
auth_chain: {
type: 'array',
items: {
type: 'object',
},
description: 'Authorization chain for the event',
},
members_omitted: {
type: 'boolean',
description: 'Whether member events were omitted',
},
origin: ServerNameSchema,
},
required: ['event', 'state', 'auth_chain', 'members_omitted', 'origin'],
};
const isSendJoinResponseProps = ajv.compile(SendJoinResponseSchema);
export const getMatrixSendJoinRoutes = () => {
// PUT /_matrix/federation/v2/send_join/{roomId}/{eventId}
// https://spec.matrix.org/v1.19/server-server-api/#put_matrixfederationv2send_joinroomideventid
return new Router('/federation').put(
'/v2/send_join/:roomId/:stateKey',
{
params: isSendJoinParamsProps,
body: isSendJoinEventProps,
response: {
200: isSendJoinResponseProps,
},
tags: ['Federation'],
license: ['federation'],
},
canAccessResourceMiddleware('room'),
async (c) => {
const { roomId, stateKey } = c.req.param();
const body = await c.req.json();
const response = await federationSDK.sendJoin(roomId, stateKey, body);
return {
body: response,
statusCode: 200,
};
},
);
};

View File

@@ -0,0 +1,109 @@
import { NotAllowedError, federationSDK } from '@rocket.chat/federation-sdk';
import { Router } from '@rocket.chat/http-router';
import { ajv } from '@rocket.chat/rest-typings';
import { logger } from '../logger';
import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated';
const isSendLeaveParamsProps = ajv.compile({
type: 'object',
properties: { roomId: { type: 'string' }, eventId: { type: 'string' } },
required: ['roomId', 'eventId'],
});
const isSendLeaveBodyProps = ajv.compile({
type: 'object',
properties: {
content: {
type: 'object',
properties: {
membership: {
type: 'string',
const: 'leave',
},
},
},
depth: {
type: 'number',
},
origin: {
type: 'string',
},
origin_server_ts: {
type: 'number',
},
sender: {
type: 'string',
},
state_key: {
type: 'string',
},
type: {
type: 'string',
const: 'm.room.member',
},
},
required: ['content', 'depth', 'origin', 'origin_server_ts', 'sender', 'state_key', 'type'],
});
const isSendLeaveSuccessResponseProps = ajv.compile({
type: 'object',
properties: {},
});
const isSendLeaveForbiddenResponseProps = ajv.compile({
type: 'object',
properties: { errcode: { type: 'string', const: 'M_FORBIDDEN' }, error: { type: 'string' } },
});
const isSendLeaveErrorResponseProps = ajv.compile({
type: 'object',
properties: { errcode: { type: 'string', const: 'M_UNKNOWN' }, error: { type: 'string' } },
});
export const getMatrixSendLeaveRoutes = () => {
// PUT /_matrix/federation/v2/send_leave/{roomId}/{eventId}
// https://spec.matrix.org/v1.19/server-server-api/#put_matrixfederationv2send_leaveroomideventid
return new Router('/federation').put(
'/v2/send_leave/:roomId/:eventId',
{
params: isSendLeaveParamsProps,
body: isSendLeaveBodyProps,
response: {
200: isSendLeaveSuccessResponseProps,
403: isSendLeaveForbiddenResponseProps,
500: isSendLeaveErrorResponseProps,
},
tags: ['Federation'],
license: ['federation'],
},
isAuthenticatedMiddleware(),
async (c) => {
const { roomId, eventId } = c.req.param();
const body = await c.req.json();
try {
await federationSDK.sendLeave(roomId, eventId, body);
return {
body: {},
statusCode: 200,
};
} catch (error) {
if (error instanceof NotAllowedError) {
return {
body: {
errcode: 'M_FORBIDDEN',
error: 'This server does not allow leaving this room based on federation settings.',
},
statusCode: 403,
};
}
logger.error({ msg: 'Error sending leave', err: error });
return {
body: {
errcode: 'M_UNKNOWN',
error: error instanceof Error ? error.message : 'Internal server error while processing request',
},
statusCode: 500,
};
}
},
);
};

View File

@@ -0,0 +1,494 @@
import type { EventID } from '@rocket.chat/federation-sdk';
import { federationSDK } from '@rocket.chat/federation-sdk';
import { Router } from '@rocket.chat/http-router';
import { ajv, ajvQuery } from '@rocket.chat/rest-typings';
import { logger } from '../logger';
import { canAccessResourceMiddleware } from '../middlewares/canAccessResource';
import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated';
const SendTransactionParamsSchema = {
type: 'object',
properties: {
txnId: {
type: 'string',
description: 'Transaction ID',
},
},
required: ['txnId'],
};
const isSendTransactionParamsProps = ajv.compile(SendTransactionParamsSchema);
const GetEventParamsSchema = {
type: 'object',
properties: {
eventId: {
type: 'string',
description: 'Event ID',
},
},
required: ['eventId'],
additionalProperties: false,
};
const isGetEventParamsProps = ajv.compile(GetEventParamsSchema);
const GetEventResponseSchema = {
type: 'object',
properties: {
origin_server_ts: {
type: 'number',
minimum: 0,
description: 'Unix timestamp in milliseconds',
},
origin: {
type: 'string',
description: 'Origin server',
},
pdus: {
type: 'array',
items: {
type: 'object',
},
description: 'Persistent data units (PDUs)',
},
},
required: ['origin_server_ts', 'origin', 'pdus'],
};
const isGetEventResponseProps = ajv.compile(GetEventResponseSchema);
const SendTransactionBodySchema = {
type: 'object',
properties: {
origin: {
type: 'string',
description: 'Origin server',
},
origin_server_ts: {
type: 'number',
minimum: 0,
description: 'Unix timestamp in milliseconds',
},
pdus: {
type: 'array',
items: {
// deliberately unconstrained, matching the spec: the PDU format varies by room
// version, and a malformed PDU must be reported per-PDU in the 200 response's
// `pdus` map instead of failing the whole transaction with a 400
type: 'object',
},
description: 'Persistent data units (PDUs) to process',
default: [],
},
edus: {
type: 'array',
items: {
type: 'object',
additionalProperties: true,
},
description: 'Ephemeral data units (EDUs)',
default: [],
nullable: true,
},
},
required: ['origin', 'origin_server_ts', 'pdus'],
};
const isSendTransactionBodyProps = ajv.compile(SendTransactionBodySchema);
const SendTransactionResponseSchema = {
type: 'object',
properties: {
pdus: {
type: 'object',
description: 'Processing results for each PDU',
},
},
required: ['pdus'],
};
const isSendTransactionResponseProps = ajv.compile(SendTransactionResponseSchema);
const ErrorResponseSchema = {
type: 'object',
properties: {
errcode: {
type: 'string',
},
error: {
type: 'string',
},
},
required: ['errcode', 'error'],
};
const isErrorResponseProps = ajv.compile(ErrorResponseSchema);
const GetStateIdsParamsSchema = {
type: 'object',
properties: {
event_id: {
type: 'string',
},
},
required: ['event_id'],
};
const isGetStateIdsParamsProps = ajv.compile(GetStateIdsParamsSchema);
const GetStateIdsResponseSchema = {
type: 'object',
properties: {
auth_chain_ids: {
type: 'array',
items: {
type: 'string',
},
description: 'Auth chain event IDs, recursively',
},
pdu_ids: {
type: 'array',
items: {
type: 'string',
},
description: 'Event IDs of the fully resolved room state at the given event',
},
},
required: ['auth_chain_ids', 'pdu_ids'],
};
const isGetStateIdsResponseProps = ajv.compile(GetStateIdsResponseSchema);
const GetStateParamsSchema = {
type: 'object',
properties: {
event_id: {
type: 'string',
},
},
};
const isGetStateParamsProps = ajv.compile<{
event_id: string;
}>(GetStateParamsSchema);
const GetStateResponseSchema = {
type: 'object',
properties: {
auth_chain: {
type: 'array',
items: {
type: 'object',
},
description: 'Auth chain events, recursively',
},
pdus: {
type: 'array',
items: {
type: 'object',
},
description: 'The fully resolved room state at the given event',
},
},
required: ['auth_chain', 'pdus'],
};
const isGetStateResponseProps = ajv.compile(GetStateResponseSchema);
const BackfillParamsSchema = {
type: 'object',
properties: {
roomId: {
type: 'string',
pattern: '^![A-Za-z0-9_=\\/.+-]+:(.+)$',
description: 'Matrix room ID',
},
},
required: ['roomId'],
additionalProperties: false,
};
const isBackfillParamsProps = ajv.compile(BackfillParamsSchema);
const BackfillQuerySchema = {
type: 'object',
properties: {
limit: {
// unbounded above per spec; the handler caps it. Synapse rejects 0 and negatives
type: 'integer',
minimum: 1,
description: 'Maximum number of events to retrieve',
},
v: {
// a string branch here would be redundant: ajvQuery coerces a single `?v=` into a
// one-element array, and in a `oneOf` both branches would match and fail validation
type: 'array',
items: { type: 'string' },
description: 'Event ID(s) to backfill from',
},
},
required: ['limit', 'v'],
};
const isBackfillQueryProps = ajvQuery.compile<{
limit: number;
v: string[];
}>(BackfillQuerySchema);
const BackfillResponseSchema = {
type: 'object',
properties: {
origin: {
type: 'string',
description: 'Origin server',
},
origin_server_ts: {
type: 'number',
minimum: 0,
description: 'Unix timestamp in milliseconds',
},
pdus: {
type: 'array',
items: {
// spec: backfill responses "MUST NOT be validated" against PDU restrictions
type: 'object',
},
description: 'Events in reverse chronological order',
},
},
required: ['origin', 'origin_server_ts', 'pdus'],
};
const isBackfillResponseProps = ajv.compile(BackfillResponseSchema);
export const getMatrixTransactionsRoutes = () => {
return (
new Router('/federation')
.use(isAuthenticatedMiddleware())
// PUT /_matrix/federation/v1/send/{txnId}
// https://spec.matrix.org/v1.19/server-server-api/#put_matrixfederationv1sendtxnid
.put(
'/v1/send/:txnId',
{
params: isSendTransactionParamsProps,
body: isSendTransactionBodyProps,
response: {
200: isSendTransactionResponseProps,
400: isErrorResponseProps,
429: isErrorResponseProps,
500: isErrorResponseProps,
},
tags: ['Federation'],
license: ['federation'],
},
async (c) => {
const body = await c.req.json();
try {
await federationSDK.processIncomingTransaction(body);
} catch (error: any) {
// TODO custom error types?
if (error.message === 'too-many-concurrent-transactions') {
return {
statusCode: 429,
body: {
errcode: 'M_UNKNOWN',
error: 'Too many concurrent transactions',
},
};
}
if (error.message === 'too-many-events') {
return {
statusCode: 400,
body: {
errcode: 'M_UNKNOWN',
error: 'Too many PDUs or EDUs',
},
};
}
// a 200 tells the origin the transaction was delivered and it will never resend
// these PDUs. Per-PDU failures are already handled by the SDK, so anything
// reaching here is a server-side failure and must be retryable - Synapse only
// backs off and redelivers on 5xx
logger.error({ msg: 'Error processing incoming transaction', err: error });
return {
statusCode: 500,
body: {
errcode: 'M_UNKNOWN',
error: 'Failed to process transaction',
},
};
}
return {
body: {
pdus: {},
},
statusCode: 200,
};
},
)
// GET /_matrix/federation/v1/state_ids/{roomId}
// https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1state_idsroomid
.get(
'/v1/state_ids/:roomId',
{
params: isGetStateIdsParamsProps,
response: {
200: isGetStateIdsResponseProps,
},
},
canAccessResourceMiddleware('room'),
async (c) => {
const roomId = c.req.param('roomId');
const eventId = c.req.query('event_id');
if (!eventId) {
return {
body: {
errcode: 'M_NOT_FOUND',
error: 'Event not found',
},
statusCode: 404,
};
}
const stateIds = await federationSDK.getStateIds(roomId, eventId as EventID);
return {
body: stateIds,
statusCode: 200,
};
},
)
// GET /_matrix/federation/v1/state/{roomId}
// https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1stateroomid
.get(
'/v1/state/:roomId',
{
params: isGetStateParamsProps,
response: {
200: isGetStateResponseProps,
},
},
canAccessResourceMiddleware('room'),
async (c) => {
const roomId = c.req.param('roomId');
const eventId = c.req.query('event_id');
if (!eventId) {
return {
body: {
errcode: 'M_NOT_FOUND',
error: 'Event not found',
},
statusCode: 404,
};
}
const state = await federationSDK.getState(roomId, eventId as EventID);
return {
statusCode: 200,
body: state,
};
},
)
// GET /_matrix/federation/v1/event/{eventId}
// https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1eventeventid
.get(
'/v1/event/:eventId',
{
params: isGetEventParamsProps,
response: {
200: isGetEventResponseProps,
},
tags: ['Federation'],
license: ['federation'],
},
canAccessResourceMiddleware('event'),
async (c) => {
const eventData = await federationSDK.getEventById(c.req.param('eventId') as EventID);
if (!eventData) {
return {
body: {
errcode: 'M_NOT_FOUND',
error: 'Event not found',
},
statusCode: 404,
};
}
return {
body: {
origin_server_ts: eventData.event.origin_server_ts,
origin: eventData.origin,
pdus: [eventData.event],
},
statusCode: 200,
};
},
)
// GET /_matrix/federation/v1/backfill/{roomId}
// https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1backfillroomid
.get(
'/v1/backfill/:roomId',
{
params: isBackfillParamsProps,
query: isBackfillQueryProps,
response: {
200: isBackfillResponseProps,
},
tags: ['Federation'],
license: ['federation'],
},
canAccessResourceMiddleware('room'),
async (c) => {
const roomId = c.req.param('roomId');
const limit = Math.min(Number(c.req.query('limit') || 100), 100);
// this will be handled by the federation-sdk on next versions so this can be removed
if (limit < 1) {
return {
body: {
origin: federationSDK.getConfig('serverName'),
origin_server_ts: Date.now(),
pdus: [],
},
statusCode: 200,
};
}
const eventIds = c.req.queries('v');
if (!eventIds?.length) {
return {
body: {
errcode: 'M_BAD_REQUEST',
error: 'Event ID must be provided in v query parameter',
},
statusCode: 400,
};
}
try {
const result = await federationSDK.getBackfillEvents(roomId, eventIds as EventID[], limit);
return {
body: result,
statusCode: 200,
};
} catch (error) {
return {
body: {
errcode: 'M_UNKNOWN',
error: 'Failed to get backfill events',
},
statusCode: 500,
};
}
},
)
);
};

View File

@@ -0,0 +1,53 @@
import { Router } from '@rocket.chat/http-router';
import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv';
const GetVersionsResponseSchema = {
type: 'object',
properties: {
server: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Server software name',
},
version: {
type: 'string',
description: 'Server software version',
},
},
required: ['name', 'version'],
},
},
required: ['server'],
};
const isGetVersionsResponseProps = ajv.compile(GetVersionsResponseSchema);
export const getFederationVersionsRoutes = (version: string) => {
// GET /_matrix/federation/v1/version
// https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1version
return new Router('/federation').get(
'/v1/version',
{
response: {
200: isGetVersionsResponseProps,
},
tags: ['Federation'],
license: ['federation'],
},
async () => {
const response = {
server: {
name: 'Rocket.Chat',
version,
},
};
return {
body: response,
statusCode: 200,
};
},
);
};

View File

@@ -0,0 +1,3 @@
import { Logger } from '@rocket.chat/logger';
export const logger = new Logger('FederationMatrixAPI');

View File

@@ -0,0 +1,56 @@
import { errCodes, federationSDK } from '@rocket.chat/federation-sdk';
import { every } from 'hono/combine';
import { createMiddleware } from 'hono/factory';
import { isAuthenticatedMiddleware } from './isAuthenticated';
function extractEntityId(
params: { eventId?: string; mediaId?: string; roomId?: string },
entityType: 'event' | 'media' | 'room',
): string | null {
if (entityType === 'room') {
return params.roomId ?? null;
}
if (entityType === 'media') {
return params.mediaId ?? null;
}
if (entityType === 'event') {
return params.eventId ?? null;
}
return null;
}
const canAccessResource = (entityType: 'event' | 'media' | 'room') =>
createMiddleware(async (c, next) => {
try {
const mediaId = c.req.param('mediaId');
const eventId = c.req.param('eventId');
const roomId = c.req.param('roomId');
const resourceId = extractEntityId({ mediaId, eventId, roomId }, entityType);
if (!resourceId) {
return c.json({ errcode: 'M_INVALID_PARAM', error: `Missing required ${entityType} identifier` }, 400);
}
const resourceAccess = await federationSDK.canAccessResource(entityType, resourceId, c.get('authenticatedServer'));
if (!resourceAccess) {
return c.json(
{
errcode: 'M_FORBIDDEN',
error: 'Access denied to resource',
},
403,
);
}
return next();
} catch (error) {
return c.json(errCodes.M_UNKNOWN, 500);
}
});
export const canAccessResourceMiddleware = (entityType: 'event' | 'media' | 'room') =>
every(isAuthenticatedMiddleware(), canAccessResource(entityType));

View File

@@ -0,0 +1,101 @@
import { errCodes, federationSDK } from '@rocket.chat/federation-sdk';
import type { Context } from 'hono';
import { createMiddleware } from 'hono/factory';
import { decodeXmppUserId, isFullXmppUserId, parseXmppUserId } from '../../helpers/parseXmppUserId';
export const isAppServiceAuthenticatedMiddleware = () =>
createMiddleware(async (c: Context, next) => {
try {
const authHeader = c.req.header('Authorization') || '';
const bearerMatch = authHeader.match(/^Bearer\s+(.+)$/i);
const token = bearerMatch?.[1] ?? c.req.query('access_token');
if (!token) {
return c.json(
{
errcode: 'M_MISSING_TOKEN',
error: 'Missing access token',
},
401,
);
}
const appService = federationSDK.getRegistrationByAsToken(token);
if (!appService) {
return c.json(
{
errcode: 'M_UNKNOWN_TOKEN',
error: 'Invalid application service token',
},
401,
);
}
c.set('appService', appService);
const appUserId = `@${appService.registration.senderLocalpart}:${federationSDK.getConfig('serverName')}`;
const userId = c.req.query('user_id');
if (!userId) {
c.set('impersonatedUserId', appUserId);
return next();
}
if (userId === appUserId) {
c.set('impersonatedUserId', userId);
return next();
}
const inNamespace = federationSDK.isUserInAppServiceNamespace(userId, appService.registration._id);
if (!inNamespace) {
return c.json(
{
errcode: 'M_FORBIDDEN',
error: 'Application service cannot masquerade as this user',
},
403,
);
}
const serverName = federationSDK.getConfig('serverName');
const decoded = decodeXmppUserId(userId);
// Not a packed XMPP JID: the registration flow stores these users under the
// full MXID as-is, so impersonate that.
if (!isFullXmppUserId(decoded)) {
c.set('impersonatedUserId', userId);
return next();
}
let decodedUsername;
try {
decodedUsername = parseXmppUserId(decoded);
} catch {
return c.json(
{
errcode: 'M_INVALID_USER_ID',
error: 'Invalid user id',
},
400,
);
}
if (!decodedUsername.resource) {
return c.json(
{
errcode: 'M_INVALID_USER_ID',
error: 'Invalid user id',
},
400,
);
}
c.set('impersonatedUserId', `${decodedUsername.resource}:${serverName}`);
return next();
} catch (error) {
return c.json(errCodes.M_UNKNOWN, 500);
}
});

View File

@@ -0,0 +1,39 @@
import { errCodes, federationSDK } from '@rocket.chat/federation-sdk';
import type { Context } from 'hono';
import { createMiddleware } from 'hono/factory';
export const isAuthenticatedMiddleware = () =>
createMiddleware(async (c: Context, next) => {
try {
const { method } = c.req;
const body = c.req.raw.body ? await c.req.raw.clone().json() : undefined;
const url = new URL(c.req.url);
const path = url.pathname + url.search;
const authHeader = c.req.header('Authorization') || '';
if (!authHeader) {
return c.json(
{
errcode: 'M_UNAUTHORIZED',
error: 'Missing Authorization header',
},
401,
);
}
const verificationResult = await federationSDK.verifyRequestSignature(authHeader, method, path, body);
if (!verificationResult) {
return c.json(
{
errcode: errCodes.M_UNAUTHORIZED.errcode,
error: errCodes.M_UNAUTHORIZED.error,
},
errCodes.M_UNAUTHORIZED.status,
);
}
c.set('authenticatedServer', verificationResult);
return next();
} catch (error) {
return c.json(errCodes.M_UNKNOWN, 500);
}
});

View File

@@ -0,0 +1,61 @@
import { Settings } from '@rocket.chat/core-services';
import { createMiddleware } from 'hono/factory';
import mem from 'mem';
// cache for 60 seconds
const getAllowList = mem(
async () => {
const allowListSetting = await Settings.get<string>('Federation_Service_Allow_List');
return allowListSetting
? allowListSetting
.split(',')
.map((d) => d.trim().toLowerCase())
.filter(Boolean)
: null;
},
{ maxAge: 60000 },
);
/**
* Parses all key-value pairs from a Matrix authorization header.
* Example: X-Matrix origin="matrix.org", key="value", ...
* Returns an object with all parsed values.
*/
// TODO make this function more of a utility if needed elsewhere
function parseMatrixAuthorizationHeader(header: string): Record<string, string> {
const result: Record<string, string> = {};
// Match key="value" pairs
const regex = /([a-zA-Z0-9_-]+)\s*=\s*"([^"]*)"/g;
let match;
while ((match = regex.exec(header)) !== null) {
result[match[1]] = match[2];
}
return result;
}
export const isFederationDomainAllowedMiddleware = createMiddleware(async (c, next) => {
const allowList = await getAllowList();
if (!allowList || allowList.length === 0) {
// No restriction, allow all
return next();
}
// Extract all key-value pairs from Matrix authorization header
const authHeader = c.req.header('authorization');
if (!authHeader) {
return c.json({ errcode: 'M_UNAUTHORIZED', error: 'Missing Authorization headers.' }, 401);
}
const authValues = parseMatrixAuthorizationHeader(authHeader);
const domain = authValues.origin?.toLowerCase();
if (!domain) {
return c.json({ errcode: 'M_MISSING_ORIGIN', error: 'Missing origin in authorization header.' }, 401);
}
// Check if domain is in allowed list (exact match only)
if (allowList.some((allowed) => domain === allowed)) {
return next();
}
return c.json({ errcode: 'M_FORBIDDEN', error: 'Federation from this domain is not allowed.' }, 403);
});

View File

@@ -0,0 +1,10 @@
import { Settings } from '@rocket.chat/core-services';
import { createMiddleware } from 'hono/factory';
export const isFederationEnabledMiddleware = createMiddleware(async (c, next) => {
// TODO use federationSDK to check if federation is enabled
if (!(await Settings.get<boolean>('Federation_Service_Enabled'))) {
return c.json({ error: 'Federation is not enabled' }, 403);
}
return next();
});

View File

@@ -0,0 +1,9 @@
import { License } from '@rocket.chat/core-services';
import { createMiddleware } from 'hono/factory';
export const isLicenseEnabledMiddleware = createMiddleware(async (c, next) => {
if (!(await License.hasModule('federation'))) {
return c.json({ error: 'Federation is not enabled' }, 403);
}
return next();
});

View File

@@ -0,0 +1,44 @@
import { Router } from '@rocket.chat/http-router';
import { getWellKnownRoutes } from './.well-known/server';
import { getClientRoutes } from './_matrix/client';
import { getMatrixInviteRoutes } from './_matrix/invite';
import { getKeyServerRoutes } from './_matrix/key/server';
import { getMatrixMakeLeaveRoutes } from './_matrix/make-leave';
import { getMatrixMediaRoutes } from './_matrix/media';
import { getMatrixMediaBridgeRoutes } from './_matrix/media-bridge';
import { getMatrixProfilesRoutes } from './_matrix/profiles';
import { getMatrixRoomsRoutes } from './_matrix/rooms';
import { getMatrixSendJoinRoutes } from './_matrix/send-join';
import { getMatrixSendLeaveRoutes } from './_matrix/send-leave';
import { getMatrixTransactionsRoutes } from './_matrix/transactions';
import { getFederationVersionsRoutes } from './_matrix/versions';
import { isFederationDomainAllowedMiddleware } from './middlewares/isFederationDomainAllowed';
import { isFederationEnabledMiddleware } from './middlewares/isFederationEnabled';
import { isLicenseEnabledMiddleware } from './middlewares/isLicenseEnabled';
export const getFederationRoutes = (version: string): { matrix: Router<'/_matrix'>; wellKnown: Router<'/.well-known'> } => {
const matrix = new Router('/_matrix');
const wellKnown = new Router('/.well-known');
matrix
.use(isFederationEnabledMiddleware)
.use(isLicenseEnabledMiddleware)
.use(getKeyServerRoutes())
.use(getFederationVersionsRoutes(version))
.use(getClientRoutes())
.use(getMatrixMediaBridgeRoutes())
.use(isFederationDomainAllowedMiddleware)
.use(getMatrixInviteRoutes())
.use(getMatrixProfilesRoutes())
.use(getMatrixRoomsRoutes())
.use(getMatrixSendJoinRoutes())
.use(getMatrixTransactionsRoutes())
.use(getMatrixMediaRoutes())
.use(getMatrixSendLeaveRoutes())
.use(getMatrixMakeLeaveRoutes());
wellKnown.use(isFederationEnabledMiddleware).use(isLicenseEnabledMiddleware).use(getWellKnownRoutes());
return { matrix, wellKnown };
};

View File

@@ -0,0 +1,114 @@
import { api, Room } from '@rocket.chat/core-services';
import { UserStatus } from '@rocket.chat/core-typings';
import { federationSDK } from '@rocket.chat/federation-sdk';
import { Logger } from '@rocket.chat/logger';
import { Messages, Rooms, Users } from '@rocket.chat/models';
const logger = new Logger('federation-matrix:edu');
export const edus = async () => {
federationSDK.eventEmitterService.on('homeserver.matrix.typing', async (data) => {
try {
const matrixRoom = await Rooms.findOne({ 'federation.mrid': data.room_id }, { projection: { _id: 1 } });
if (!matrixRoom) {
logger.debug({ msg: 'No bridged room found for Matrix room_id', roomId: data.room_id });
return;
}
void api.broadcast('user.activity', {
user: data.user_id,
isTyping: data.typing,
roomId: matrixRoom._id,
});
} catch (err) {
logger.error({ msg: 'Error handling Matrix typing event', err });
}
});
federationSDK.eventEmitterService.on('homeserver.matrix.presence', async (data) => {
try {
const matrixUser = await Users.findOneByUsername(data.user_id);
if (!matrixUser) {
logger.debug({ msg: 'No federated user found for Matrix user_id', userId: data.user_id });
return;
}
if (!matrixUser.federated) {
logger.debug({ msg: 'User is not federated, skipping presence update from Matrix', username: matrixUser.username });
return;
}
const statusMap = {
online: UserStatus.ONLINE,
offline: UserStatus.OFFLINE,
unavailable: UserStatus.AWAY,
};
const status = statusMap[data.presence] || UserStatus.OFFLINE;
if (matrixUser.status === status) {
logger.debug({ msg: 'User already has target status, skipping update', username: matrixUser.username, status });
return;
}
await Users.updateOne(
{ _id: matrixUser._id },
{
$set: {
status,
statusDefault: status,
},
},
);
const { _id, username, statusText, roles, name } = matrixUser;
void api.broadcast('presence.status', {
user: { status, _id, username, statusText, roles, name },
previousStatus: undefined,
});
logger.debug({ msg: 'Updated presence for user from Matrix federation', userId: matrixUser._id, status });
} catch (err) {
logger.error({ msg: 'Error handling Matrix presence event', err });
}
});
federationSDK.eventEmitterService.on('homeserver.matrix.receipt', async (data) => {
try {
const matrixUser = await Users.findOneByUsername(data.user_id);
if (!matrixUser) {
logger.debug({ msg: 'No federated user found for Matrix user_id', userId: data.user_id });
return;
}
const matrixRoom = await Rooms.findOne({ 'federation.mrid': data.room_id });
if (!matrixRoom) {
logger.debug({ msg: 'No bridged room found for Matrix room_id', roomId: data.room_id });
return;
}
if (data.thread_id && data.thread_id !== 'main') {
const msg = await Messages.findOneByFederationId(data.thread_id);
if (!msg) {
logger.debug({ msg: 'No message found for Matrix thread_id', threadId: data.thread_id });
return;
}
if (msg.rid !== matrixRoom._id) {
logger.warn({
msg: 'Message thread_id does not belong to the expected room',
threadId: data.thread_id,
expectedRoomId: matrixRoom._id,
actualRoomId: msg.rid,
});
return;
}
await Room.readThread({ room: matrixRoom, user: matrixUser, tmid: msg._id });
} else {
await Room.markAsRead(matrixRoom, matrixUser._id);
}
} catch (err) {
logger.error({ msg: 'Error handling Matrix receipt event', err });
}
});
};

View File

@@ -0,0 +1,15 @@
import { edus } from './edu';
import { member } from './member';
import { message } from './message';
import { ping } from './ping';
import { reaction } from './reaction';
import { room } from './room';
export function registerEvents() {
void ping();
message();
reaction();
member();
void edus();
room();
}

View File

@@ -0,0 +1,452 @@
import { Room, Upload } from '@rocket.chat/core-services';
import { isBannedSubscription } from '@rocket.chat/core-typings';
import type { IRoomNativeFederated, IRoom, IUser, RoomType } from '@rocket.chat/core-typings';
import { federationSDK, type HomeserverEventSignatures, type PduForType } from '@rocket.chat/federation-sdk';
import { Logger } from '@rocket.chat/logger';
import { Rooms, Subscriptions, Users } from '@rocket.chat/models';
import debounce from 'lodash.debounce';
import mem from 'mem';
import { extractDomainFromMatrixUserId } from '../helpers/extractDomainFromMatrixUserId';
import { getFederatedRoomName } from '../helpers/getFederatedRoomName';
import { getOrCreateFederatedUser } from '../helpers/getOrCreateFederatedUser';
import { getUsernameServername } from '../helpers/getUsernameServername';
import { MatrixMediaService } from '../services/MatrixMediaService';
const logger = new Logger('federation-matrix:member');
async function downloadAndSetAvatar(user: IUser, avatarUrl: string | null): Promise<void> {
try {
// if no avatarUrl is provided, it means the user removed his avatar, so we need to set an empty avatar to remove the avatar from their side as well
if (!avatarUrl) {
await Upload.resetUserAvatar(user);
return;
}
if (!avatarUrl?.startsWith('mxc://')) {
return;
}
logger.debug(`Downloading avatar for user ${user.username}: ${avatarUrl}`);
const parsed = MatrixMediaService.parseMXCUri(avatarUrl);
if (!parsed) {
logger.warn(`Invalid MXC URI: ${avatarUrl}`);
return;
}
const buffer = await federationSDK.downloadFromRemoteServer(parsed.serverName, parsed.mediaId);
if (!buffer) {
logger.warn(`Failed to download avatar from ${avatarUrl}`);
return;
}
// detect content type from buffer (basic image type detection)
let contentType: string | undefined;
if (buffer[0] === 0xff && buffer[1] === 0xd8) {
contentType = 'image/jpeg';
} else if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
contentType = 'image/png';
} else if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) {
contentType = 'image/gif';
} else if (
buffer[0] === 0x52 &&
buffer[1] === 0x49 &&
buffer[2] === 0x46 &&
buffer[3] === 0x46 &&
buffer[8] === 0x57 &&
buffer[9] === 0x45 &&
buffer[10] === 0x42 &&
buffer[11] === 0x50
) {
contentType = 'image/webp';
}
if (!contentType) {
logger.warn({ msg: 'Unsupported remote avatar format from external server', username: user.username, avatarUrl });
return;
}
// TODO need to perform a validation to check if the user actually changed avatar
await Upload.setUserAvatar(user, buffer, contentType, 'rest');
} catch (error) {
logger.error({ err: error, user: user.username, msg: `Error downloading/setting avatar for user` });
}
}
async function getOrCreateFederatedRoom({
matrixRoomId,
roomName,
roomFName,
roomType,
inviterUserId,
inviterUsername,
inviteeUsername,
}: {
matrixRoomId: string;
roomName: string;
roomFName: string;
roomType: RoomType;
inviterUserId: string;
inviterUsername: string;
inviteeUsername?: string;
}): Promise<IRoom> {
try {
const room = await Rooms.findOne({ 'federation.mrid': matrixRoomId });
if (room) {
return room;
}
const origin = matrixRoomId.split(':').pop();
if (!origin) {
throw new Error(`Room origin not found for Matrix ID: ${matrixRoomId}`);
}
// TODO room creator is not always the inviter
return Room.create<IRoomNativeFederated>(inviterUserId, {
type: roomType,
name: roomName,
members: inviteeUsername ? [inviteeUsername, inviterUsername] : [inviterUsername],
options: {
forceNew: true, // an invite means the room does not exist yet
creator: inviterUserId,
},
extraData: {
federated: true,
federation: {
version: 1,
mrid: matrixRoomId,
origin,
},
...(roomType !== 'd' && { fname: roomFName }), // DMs do not have a fname
},
});
} catch (err) {
logger.error({ msg: 'Error getting or creating federated room', err, roomName });
throw new Error(`Error getting or creating federated room ${roomName}`);
}
}
// get the join rule type from the stripped state stored in the unsigned section of the event
// as per the spec, we must support several types but we only support invite and public for now.
// in the future, we must start looking into 'knock', 'knock_restricted', 'restricted' and 'private'.
function getJoinRuleType(strippedState: PduForType<'m.room.join_rules'>[]): 'p' | 'c' | 'd' {
const joinRulesState = strippedState?.find((state: PduForType<'m.room.join_rules'>) => state.type === 'm.room.join_rules');
// as per the spec, users need to be invited to join a room, unless the room’s join rules state otherwise.
if (!joinRulesState) {
return 'p';
}
const joinRule = joinRulesState?.content?.join_rule;
switch (joinRule) {
case 'invite':
return 'p';
case 'public':
return 'c';
case 'knock':
throw new Error(`Knock join rule is not supported`);
case 'knock_restricted':
throw new Error(`Knock restricted join rule is not supported`);
case 'restricted':
throw new Error(`Restricted join rule is not supported`);
case 'private':
throw new Error(`Private join rule is not supported`);
default:
throw new Error(`Unknown join rule type: ${joinRule}`);
}
}
async function handleInvite({
sender: senderId,
state_key: userId,
room_id: roomId,
content,
unsigned,
}: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise<void> {
const inviterUser = await getOrCreateFederatedUser(senderId);
const inviteeUser = await getOrCreateFederatedUser(userId);
const strippedState = unsigned.invite_room_state;
const joinRuleType = getJoinRuleType(strippedState);
const roomOriginDomain = senderId.split(':')?.pop();
if (!roomOriginDomain) {
throw new Error(`Room origin domain not found: ${roomId}`);
}
const roomNameState = strippedState?.find((state: PduForType<'m.room.name'>) => state.type === 'm.room.name');
const matrixRoomName = roomNameState?.content?.name;
// DMs do not have a join rule type (they are treated as invite only rooms),
// so we use 'd' for direct messages translation to RC.
const roomType = content?.is_direct || !matrixRoomName ? 'd' : joinRuleType;
let roomName: string;
let roomFName: string;
if (roomType === 'd') {
roomName = senderId;
roomFName = senderId;
} else {
roomName = getFederatedRoomName(roomId);
roomFName = `${matrixRoomName}:${roomOriginDomain}`;
}
const room = await getOrCreateFederatedRoom({
matrixRoomId: roomId,
roomName,
roomFName,
roomType,
inviterUserId: inviterUser._id,
inviterUsername: inviterUser.username,
inviteeUsername: roomType === 'd' ? inviteeUser.username : undefined,
});
if (!room) {
throw new Error(`Room not found or could not be created: ${roomId}`);
}
const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, inviteeUser._id);
if (subscription) {
// if subscription state says the user is banned, it means the user was previously banned and is now being re-invited,
// so we need to unban the user instead of creating a new invite
if (isBannedSubscription(subscription)) {
await Room.unbanAndInviteUser(subscription, inviteeUser, inviterUser);
}
return;
}
await Room.createUserSubscription({
ts: new Date(),
room,
userToBeAdded: inviteeUser,
inviter: inviterUser,
status: 'INVITED',
});
// if an invite is sent to a DM, we need to update the room name to reflect all participants
if (room.t === 'd') {
await Room.updateDirectMessageRoomName(room);
}
}
const getUpdateUserNameDebounced = mem((userId: string) => debounce((name: string) => Users.setName(userId, name), 1000));
function updateUserNameDebounced(userId: string, newName: string): void {
void getUpdateUserNameDebounced(userId)(newName);
}
const getDownloadAndSetAvatarDebounced = mem((_userId: string) =>
debounce((user: IUser, avatarUrl: string | null) => downloadAndSetAvatar(user, avatarUrl), 2000),
);
function downloadAndSetAvatarDebounced(userId: string, user: IUser, newAvatarUrl: string | null): void {
void getDownloadAndSetAvatarDebounced(userId)(user, newAvatarUrl);
}
async function handleJoin({
room_id: roomId,
state_key: userId,
content,
}: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise<void> {
const joiningUser = await getOrCreateFederatedUser(userId);
const room = await Rooms.findOneFederatedByMrid(roomId);
if (!room) {
throw new Error(`Room not found while joining user ${userId} to room ${roomId}`);
}
// if we receive a join event but still don't have a subscription for the user,
// it means the join event was sent before the invite event, so we need to create the subscription and then accept the invite.
// this will happen when for example the user is unbanned, so the leave event will remove the subscription and then we just
// receive the join event without receiving the invite.
let subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, joiningUser._id);
if (!subscription) {
const subId = await Room.createUserSubscription({
ts: new Date(),
room,
userToBeAdded: joiningUser,
});
subscription = subId ? await Subscriptions.findOneById(subId) : null;
}
if (!subscription) {
throw new Error(`Subscription not found while joining user ${userId} to room ${roomId}`);
}
const senderServerName = extractDomainFromMatrixUserId(userId);
// handle avatar updates to membership events
if (senderServerName !== federationSDK.getConfig('serverName')) {
// TODO if there is no avatar_url we may want to validate first if we should remove the user avatar because if may be dealing with an old join event, and the user may have changed their avatar since then, so we need to check if the avatar_url is different from the current one before removing it
void downloadAndSetAvatarDebounced(joiningUser._id, joiningUser, content.avatar_url || null);
}
// updates user name whenever we receive a join event, because Matrix sends a new join event with the updated display name whenever a user changes their display name
if ('displayname' in content && content.displayname !== joiningUser.name) {
// whan a user changes the it's display name we receive a new join event for every room the user is in
// so we need to debounce the name update to avoid updating the name multiple times in a row
void updateUserNameDebounced(joiningUser._id, content.displayname || '');
}
if (room.t === 'd') {
await Room.updateDirectMessageRoomName(
room,
[subscription._id],
[{ _id: joiningUser._id, name: content.displayname || joiningUser.name, username: joiningUser.username }],
);
}
if (!subscription.status) {
logger.info('User is already joined to the room, skipping...');
return;
}
await Room.performAcceptRoomInvite(room, subscription, joiningUser);
}
async function handleLeave({
room_id: roomId,
state_key: userId,
sender,
unsigned: { prev_content: prevContent },
}: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise<void> {
const serverName = federationSDK.getConfig('serverName');
const [username] = getUsernameServername(userId, serverName);
const leavingUser = await Users.findOneByUsername(username);
if (!leavingUser) {
return;
}
const [senderUsername] = getUsernameServername(sender, serverName);
const senderUser = await Users.findOneByUsername(senderUsername);
if (!senderUser) {
return;
}
const room = await Rooms.findOneFederatedByMrid(roomId);
if (!room) {
throw new Error(`Room not found while leaving user ${userId} from room ${roomId}`);
}
// In Matrix, unban is a leave event for a previously banned user.
// Check local subscription state to distinguish leave from unban.
const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, leavingUser._id);
if (subscription && isBannedSubscription(subscription)) {
await Room.performUserUnban(room, leavingUser, senderUser);
logger.info({ msg: 'Unbanned user via federation leave event', userId: leavingUser._id, roomId: room._id });
return;
}
// this means the leave event is actually an unban
if (prevContent?.membership === 'ban') {
await Room.performUserUnban(room, leavingUser, senderUser);
} else {
await Room.performUserRemoval(room, leavingUser);
}
// update room name for DMs
if (room.t === 'd') {
await Room.updateDirectMessageRoomName(room);
}
// TODO check if there are no pending invites to the room, and if so, delete the room
}
async function handleBan({
room_id: roomId,
state_key: userId,
sender: senderId,
}: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise<void> {
const serverName = federationSDK.getConfig('serverName');
const [username] = getUsernameServername(userId, serverName);
const bannedUser = await Users.findOneByUsername(username);
if (!bannedUser) {
return;
}
const room = await Rooms.findOneFederatedByMrid(roomId);
if (!room) {
throw new Error(`Room not found while banning user ${userId} from room ${roomId}`);
}
const [senderUsername] = getUsernameServername(senderId, serverName);
const senderUser = await Users.findOneByUsername(senderUsername);
if (!senderUser) {
throw new Error(`Ban sender not found locally: ${senderUsername} (Matrix id ${senderId})`);
}
await Room.performUserBan(room, bannedUser, senderUser);
}
async function handleMembershipRejected({
event,
reason,
}: HomeserverEventSignatures['homeserver.matrix.membership.rejected']): Promise<void> {
const room = await Rooms.findOne({ 'federation.mrid': event.room_id });
if (!room) {
logger.debug({ msg: 'No local room found for rejected membership event', roomId: event.room_id, reason });
return;
}
const serverName = federationSDK.getConfig('serverName');
const [username] = getUsernameServername(event.state_key, serverName);
const user = await Users.findOneByUsername(username);
if (!user) {
logger.debug({ msg: 'User not found for rejected membership event', userId: event.state_key, reason });
return;
}
await Room.revokeInvite(room, user);
logger.info({
msg: 'Revoked invite due to rejected membership event',
userId: user._id,
roomId: room._id,
membership: event.content.membership,
reason,
});
}
export function member() {
federationSDK.eventEmitterService.on('homeserver.matrix.membership', async ({ event }) => {
try {
switch (event.content.membership) {
case 'invite':
await handleInvite(event);
break;
case 'join':
await handleJoin(event);
break;
case 'leave':
await handleLeave(event);
break;
case 'ban':
await handleBan(event);
break;
default:
logger.warn({ msg: 'Unknown membership type', membership: event.content.membership });
}
} catch (err) {
logger.error({ msg: 'Failed to process Matrix membership event', err });
}
});
federationSDK.eventEmitterService.on('homeserver.matrix.membership.rejected', async (payload) => {
try {
await handleMembershipRejected(payload);
} catch (err) {
logger.error({ msg: 'Failed to process rejected membership event', err });
}
});
}

View File

@@ -0,0 +1,166 @@
import { FederationMatrix, Message } from '@rocket.chat/core-services';
import { federationSDK } from '@rocket.chat/federation-sdk';
import { Logger } from '@rocket.chat/logger';
import { Users, Rooms, Messages } from '@rocket.chat/models';
import { getThreadMessageId } from '../helpers/getThreadMessageId';
const logger = new Logger('federation-matrix:message');
export function message() {
federationSDK.eventEmitterService.on('homeserver.matrix.message', async (event) => {
try {
await FederationMatrix.saveFederationMessage(event);
} catch (err) {
logger.error({ msg: 'Error processing Matrix message', err });
}
});
federationSDK.eventEmitterService.on('homeserver.matrix.encrypted', async ({ event, event_id: eventId }) => {
try {
if (!event.content.ciphertext) {
logger.debug('No message content found in event');
return;
}
// at this point we know for sure the user already exists
const user = await Users.findOneByUsername(event.sender);
if (!user) {
throw new Error(`User not found for sender: ${event.sender}`);
}
const room = await Rooms.findOne({ 'federation.mrid': event.room_id });
if (!room) {
throw new Error(`No mapped room found for room_id: ${event.room_id}`);
}
const relation = event.content['m.relates_to'];
// SPEC: For example, an m.thread relationship type denotes that the event is part of a “thread” of messages and should be rendered as such.
const hasRelation = relation && 'rel_type' in relation;
const isThreadMessage = hasRelation && relation.rel_type === 'm.thread';
const threadRootEventId = isThreadMessage && relation.event_id;
// SPEC: Though rich replies form a relationship to another event, they do not use rel_type to create this relationship.
// Instead, a subkey named m.in_reply_to is used to describe the reply’s relationship,
const isRichReply = relation && !('rel_type' in relation) && 'm.in_reply_to' in relation;
const quoteMessageEventId = isRichReply && relation['m.in_reply_to']?.event_id;
const thread = threadRootEventId ? await getThreadMessageId(threadRootEventId) : undefined;
const isEditedMessage = hasRelation && relation.rel_type === 'm.replace';
if (isEditedMessage && relation.event_id) {
logger.debug('Received edited message from Matrix, updating existing message');
const originalMessage = await Messages.findOneByFederationId(relation.event_id);
if (!originalMessage) {
logger.error({ event_id: relation.event_id, msg: 'Original message not found for edit' });
return;
}
if (originalMessage.federation?.eventId !== relation.event_id) {
return;
}
if (originalMessage.content?.ciphertext === event.content.ciphertext) {
logger.debug('No changes in message content, skipping update');
return;
}
if (quoteMessageEventId) {
await Message.updateMessage(
{
...originalMessage,
content: {
algorithm: event.content.algorithm,
ciphertext: event.content.ciphertext,
},
},
user,
originalMessage,
);
return;
}
await Message.updateMessage(
{
...originalMessage,
content: {
algorithm: event.content.algorithm,
ciphertext: event.content.ciphertext,
},
},
user,
originalMessage,
);
return;
}
if (quoteMessageEventId) {
const originalMessage = await Messages.findOneByFederationId(quoteMessageEventId);
if (!originalMessage) {
logger.error({ quoteMessageEventId, msg: 'Original message not found for quote' });
return;
}
await Message.saveMessageFromFederation({
fromId: user._id,
rid: room._id,
e2e_content: {
algorithm: event.content.algorithm,
ciphertext: event.content.ciphertext,
},
federation_event_id: eventId,
thread,
ts: new Date(event.origin_server_ts),
});
return;
}
await Message.saveMessageFromFederation({
fromId: user._id,
rid: room._id,
e2e_content: {
algorithm: event.content.algorithm,
ciphertext: event.content.ciphertext,
},
federation_event_id: eventId,
thread,
ts: new Date(event.origin_server_ts),
});
} catch (err) {
logger.error({ msg: 'Error processing Matrix message', err });
}
});
federationSDK.eventEmitterService.on('homeserver.matrix.redaction', async ({ event }) => {
try {
const redactedEventId = event.redacts;
if (!redactedEventId) {
logger.debug('No redacts field in redaction event');
return;
}
const messageEvent = await FederationMatrix.getEventById(redactedEventId);
if (messageEvent?.event.type !== 'm.room.message') {
logger.debug({ msg: 'Event is not a message event', eventId: redactedEventId });
return;
}
const rcMessage = await Messages.findOneByFederationId(event.redacts);
if (!rcMessage) {
logger.debug({ msg: 'No RC message found for event', eventId: event.redacts });
return;
}
const internalUsername = event.sender;
const user = await Users.findOneByUsername(internalUsername);
if (!user) {
logger.debug({ msg: 'User not found', username: internalUsername });
return;
}
await Message.deleteMessage(user, rcMessage);
} catch (err) {
logger.error({ msg: 'Failed to process Matrix removal redaction', err });
}
});
}

View File

@@ -0,0 +1,7 @@
import { federationSDK } from '@rocket.chat/federation-sdk';
export const ping = async () => {
federationSDK.eventEmitterService.on('homeserver.ping', async (data) => {
console.log('Message received from homeserver', data);
});
};

View File

@@ -0,0 +1,103 @@
import { Message, FederationMatrix } from '@rocket.chat/core-services';
import { federationSDK } from '@rocket.chat/federation-sdk';
import { Logger } from '@rocket.chat/logger';
import { Users, Messages } from '@rocket.chat/models'; // Rooms
import { unicodeToShortname } from '../utils/emojiConverter';
const logger = new Logger('federation-matrix:reaction');
export function reaction() {
federationSDK.eventEmitterService.on('homeserver.matrix.reaction', async ({ event, event_id: eventId }) => {
try {
const isSetReaction = event.content?.['m.relates_to'];
const reactionTargetEventId = isSetReaction?.event_id;
const reactionKey = isSetReaction?.key;
if (typeof reactionKey !== 'string' || reactionKey.length === 0) {
logger.error({ reactionTargetEventId, reactionKey, msg: 'Invalid or missing reaction key in Matrix reaction event' });
return;
}
const [userPart, domain] = event.sender.split(':');
if (!userPart || !domain) {
logger.error({ sender: event.sender, msg: 'Invalid Matrix sender ID format' });
return;
}
const internalUsername = event.sender;
const user = await Users.findOneByUsername(internalUsername);
if (!user) {
logger.error({ reactionTargetEventId, internalUsername, msg: 'No RC user mapping found for Matrix event' });
return;
}
if (!isSetReaction) {
logger.debug('No relates_to content in reaction event');
return;
}
const rcMessage = await Messages.findOneByFederationId(reactionTargetEventId);
if (!rcMessage) {
logger.debug({ msg: 'No RC message mapping found for Matrix event', eventId: reactionTargetEventId });
return;
}
const reactionEmoji = unicodeToShortname(reactionKey);
await Message.reactToMessage(user._id, reactionEmoji, rcMessage._id, true);
await Messages.setFederationReactionEventId(internalUsername, rcMessage._id, reactionEmoji, eventId);
} catch (err) {
logger.error({ msg: 'Failed to process Matrix reaction', err });
}
});
federationSDK.eventEmitterService.on('homeserver.matrix.redaction', async ({ event }) => {
try {
const redactedEventId = event.redacts;
if (!redactedEventId) {
logger.debug('No redacts field in redaction event');
return;
}
const reactionEvent = await FederationMatrix.getEventById(redactedEventId);
if (!reactionEvent || reactionEvent.event.type !== 'm.reaction') {
logger.debug({ msg: 'Event is not a reaction event', eventId: redactedEventId });
return;
}
const reactionContent = reactionEvent.event.content?.['m.relates_to'];
if (!reactionContent) {
logger.debug('No relates_to content in reaction event');
return;
}
const targetMessageEventId = reactionContent.event_id;
const reactionKey = reactionContent.key;
if (typeof reactionKey !== 'string' || reactionKey.length === 0) {
logger.error({ redactedEventId, reactionKey, msg: 'Invalid or missing reaction key in redacted reaction event' });
return;
}
const rcMessage = await Messages.findOneByFederationId(targetMessageEventId);
if (!rcMessage) {
logger.debug({ msg: 'No RC message found for event', eventId: targetMessageEventId });
return;
}
const internalUsername = event.sender;
const user = await Users.findOneByUsername(internalUsername);
if (!user) {
logger.debug({ msg: 'User not found', username: internalUsername });
return;
}
const reactionEmoji = unicodeToShortname(reactionKey);
await Message.reactToMessage(user._id, reactionEmoji, rcMessage._id, false);
await Messages.unsetFederationReactionEventId(redactedEventId, rcMessage._id, reactionEmoji);
} catch (err) {
logger.error({ msg: 'Failed to process Matrix reaction redaction', err });
}
});
}

View File

@@ -0,0 +1,87 @@
import { Room } from '@rocket.chat/core-services';
import { federationSDK } from '@rocket.chat/federation-sdk';
import { Rooms, Users } from '@rocket.chat/models';
import { getUsernameServername } from '../helpers/getUsernameServername';
export function room() {
federationSDK.eventEmitterService.on('homeserver.matrix.room.name', async ({ event }) => {
const {
room_id: roomId,
content: { name },
sender: userId,
} = event;
const localRoomId = await Rooms.findOne({ 'federation.mrid': roomId }, { projection: { _id: 1 } });
if (!localRoomId) {
throw new Error(`mapped room not found: ${roomId}`);
}
const localUserId = await Users.findOneByUsername(userId, { projection: { _id: 1 } });
if (!localUserId) {
throw new Error(`mapped user not found: ${userId}`);
}
await Room.saveRoomName(localRoomId._id, localUserId._id, name);
});
federationSDK.eventEmitterService.on('homeserver.matrix.room.topic', async ({ event }) => {
const {
room_id: roomId,
content: { topic },
sender: userId,
} = event;
const localRoomId = await Rooms.findOne({ 'federation.mrid': roomId }, { projection: { _id: 1 } });
if (!localRoomId) {
throw new Error('mapped room not found');
}
const localUser = await Users.findOneByUsername(userId, { projection: { _id: 1, federation: 1, federated: 1 } });
if (!localUser) {
throw new Error('mapped user not found');
}
await Room.saveRoomTopic(localRoomId._id, topic, {
_id: localUser._id,
username: userId,
federation: localUser.federation,
federated: localUser.federated,
});
});
federationSDK.eventEmitterService.on('homeserver.matrix.room.role', async (data) => {
const { room_id: roomId, user_id: userId, sender_id: senderId, role } = data;
const localRoomId = await Rooms.findOne({ 'federation.mrid': roomId }, { projection: { _id: 1 } });
if (!localRoomId) {
throw new Error('mapped room not found');
}
const serverName = federationSDK.getConfig('serverName');
const [allegedUsernameLocal, , allegedUserLocalIsLocal] = getUsernameServername(userId, serverName);
const localUserId = allegedUserLocalIsLocal && (await Users.findOneByUsername(allegedUsernameLocal, { projection: { _id: 1 } }));
if (!allegedUserLocalIsLocal) {
return;
}
if (!localUserId) {
throw new Error('mapped user not found');
}
const [senderUsername, , senderIsLocal] = getUsernameServername(senderId, serverName);
if (senderIsLocal) {
return;
}
const localSenderId = await Users.findOneByUsername(senderUsername, { projection: { _id: 1 } });
if (!localSenderId) {
throw new Error('mapped user not found');
}
await Room.addUserRoleRoomScoped(localSenderId._id, localUserId._id, localRoomId._id, role);
});
}

View File

@@ -0,0 +1,144 @@
import { UserStatus } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import { createOrUpdateFederatedUser } from './createOrUpdateFederatedUser';
jest.mock('@rocket.chat/models', () => ({
Users: {
findOneAndUpdate: jest.fn(),
},
}));
const mockFindOneAndUpdate = Users.findOneAndUpdate as jest.MockedFunction<typeof Users.findOneAndUpdate>;
const fakeUser = {
_id: 'user123',
username: '@alice:example.com',
name: '@alice:example.com',
federated: true,
federation: { version: 1, mui: '@alice:example.com', origin: 'example.com' },
};
describe('createOrUpdateFederatedUser', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should assign the "federated" role to the new user', async () => {
mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any);
await createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' });
expect(mockFindOneAndUpdate).toHaveBeenCalledTimes(1);
const [, updateDoc] = mockFindOneAndUpdate.mock.calls[0];
expect((updateDoc as any).$set.roles).toEqual(['federated-external']);
});
it('should not assign the "user" role to federated users', async () => {
mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any);
await createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' });
const [, updateDoc] = mockFindOneAndUpdate.mock.calls[0];
expect((updateDoc as any).$set.roles).not.toContain('user');
});
it('should set federated=true on the created/updated user', async () => {
mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any);
await createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' });
const [, updateDoc] = mockFindOneAndUpdate.mock.calls[0];
expect((updateDoc as any).$set.federated).toBe(true);
});
it('should use the provided name when supplied', async () => {
mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any);
await createOrUpdateFederatedUser({ username: '@alice:example.com', name: 'Alice', origin: 'example.com' });
const [, updateDoc] = mockFindOneAndUpdate.mock.calls[0];
expect((updateDoc as any).$set.name).toBe('Alice');
});
it('should default name to username when name is not provided', async () => {
mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any);
await createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' });
const [, updateDoc] = mockFindOneAndUpdate.mock.calls[0];
expect((updateDoc as any).$set.name).toBe('@alice:example.com');
});
it('should set initial status to OFFLINE', async () => {
mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any);
await createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' });
const [, updateDoc] = mockFindOneAndUpdate.mock.calls[0];
expect((updateDoc as any).$set.status).toBe(UserStatus.OFFLINE);
});
it('should store the origin server in the federation object', async () => {
mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any);
await createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' });
const [, updateDoc] = mockFindOneAndUpdate.mock.calls[0];
expect((updateDoc as any).$set.federation.origin).toBe('example.com');
});
it('should use upsert so the user is created if not found', async () => {
mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any);
await createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' });
const [, , options] = mockFindOneAndUpdate.mock.calls[0];
expect((options as any).upsert).toBe(true);
});
it('should throw when findOneAndUpdate returns null', async () => {
mockFindOneAndUpdate.mockResolvedValueOnce(null as any);
await expect(createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' })).rejects.toThrow(
'Failed to create or update federated user: @alice:example.com',
);
});
it('should throw when the returned document is not a native federated user', async () => {
mockFindOneAndUpdate.mockResolvedValueOnce({ _id: 'user123', username: '@alice:example.com', name: 'Alice' } as any);
await expect(createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' })).rejects.toThrow(
'Failed to create or update federated user: @alice:example.com',
);
});
// callers subscribe the returned document to a room, so a projection here silently hands them a
// user missing every field but the two projected ones
it('should not project fields away, so the full user document is returned', async () => {
mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any);
await createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' });
const [, , options] = mockFindOneAndUpdate.mock.calls[0];
expect(options).not.toHaveProperty('projection');
});
// the pre-image of an upsert that inserted is null, which would fail every first contact
it('should ask for the document as it looks after the upsert', async () => {
mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any);
await createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' });
const [, , options] = mockFindOneAndUpdate.mock.calls[0];
expect((options as any).returnDocument).toBe('after');
});
it('should return the user returned by findOneAndUpdate', async () => {
mockFindOneAndUpdate.mockResolvedValueOnce(fakeUser as any);
const result = await createOrUpdateFederatedUser({ username: '@alice:example.com', origin: 'example.com' });
expect(result).toBe(fakeUser);
});
});

View File

@@ -0,0 +1,65 @@
import {
type IRegisterUser,
type IUserNativeFederated,
isRegisterUser,
isUserNativeFederated,
UserStatus,
} from '@rocket.chat/core-typings';
import type { UserID } from '@rocket.chat/federation-sdk';
import { Users } from '@rocket.chat/models';
/**
* Helper function to create a federated user
*
* Because of historical reasons, we can have users only with federated flag but no federation object
* So we need to upsert the user with the federation object
*/
export async function createOrUpdateFederatedUser(options: {
username: UserID;
name?: string;
origin: string;
asId?: string;
}): Promise<IUserNativeFederated & IRegisterUser> {
const { username, name = username, origin, asId } = options;
// TODO: Have a specific method to handle this upsert
const user = await Users.findOneAndUpdate(
{
username,
},
{
$set: {
username,
name: name || username,
type: 'user' as const,
status: UserStatus.OFFLINE,
active: true,
roles: ['federated-external'],
requirePasswordChange: false,
federated: true,
federation: {
version: 1,
mui: username,
origin,
...(asId && { asId }),
},
_updatedAt: new Date(),
},
$setOnInsert: {
createdAt: new Date(),
},
},
{
upsert: true,
returnDocument: 'after',
},
);
// the upsert above writes every field these guards check, so they should never reject a returned document
if (!user || !isUserNativeFederated(user) || !isRegisterUser(user)) {
throw new Error(`Failed to create or update federated user: ${username}`);
}
return user;
}

View File

@@ -0,0 +1,7 @@
export const extractDomainFromMatrixUserId = (mxid: string): string => {
const separatorIndex = mxid.indexOf(':', 1);
if (separatorIndex === -1) {
throw new Error(`Invalid federated username: ${mxid}`);
}
return mxid.substring(separatorIndex + 1);
};

View File

@@ -0,0 +1,32 @@
import { getFederatedRoomName } from './getFederatedRoomName';
describe('getFederatedRoomName', () => {
it('should strip the leading `!` sigil and replace the `:` separator with `_`', () => {
expect(getFederatedRoomName('!abcdef:matrix.org')).toBe('abcdef_matrix.org');
});
it('should produce a slug-valid name (only [0-9a-zA-Z-_.])', () => {
expect(getFederatedRoomName('!abcdef:matrix.org')).toMatch(/^[0-9a-zA-Z-_.]+$/);
});
it('should handle server names with a port (multiple `:`)', () => {
expect(getFederatedRoomName('!abcdef:matrix.org:8448')).toBe('abcdef_matrix.org_8448');
});
it('should replace slug-invalid characters in the opaque id', () => {
expect(getFederatedRoomName('!ab+cd/ef=:matrix.org')).toBe('ab_cd_ef__matrix.org');
expect(getFederatedRoomName('!ab+cd/ef=:matrix.org')).toMatch(/^[0-9a-zA-Z-_.]+$/);
});
it('should only strip a `!` at the start, not elsewhere', () => {
expect(getFederatedRoomName('!ab!cd:matrix.org')).toBe('ab_cd_matrix.org');
});
it('should be deterministic for the same room id', () => {
expect(getFederatedRoomName('!room:server.com')).toBe(getFederatedRoomName('!room:server.com'));
});
it('should derive distinct names for distinct room ids', () => {
expect(getFederatedRoomName('!a:server.com')).not.toBe(getFederatedRoomName('!b:server.com'));
});
});

View File

@@ -0,0 +1,7 @@
// Derives a valid Rocket.Chat room name (slug) from a Matrix room id.
// Matrix room ids look like `!opaqueId:server.domain`; we strip the leading `!`
// sigil and replace every char RC rejects in a slug (including the `:` separator,
// which may also appear in a server port) with `_`, producing a deterministic,
// slug-valid name. Matrix rooms may have no name (or a name with characters RC
// rejects), so the room id is the only always-present, addressable identifier.
export const getFederatedRoomName = (matrixRoomId: string): string => matrixRoomId.replace(/^!/, '').replace(/[^0-9a-zA-Z-_.]/g, '_');

View File

@@ -0,0 +1,179 @@
import { federationSDK } from '@rocket.chat/federation-sdk';
import { Users } from '@rocket.chat/models';
import { createOrUpdateFederatedUser } from './createOrUpdateFederatedUser';
import { getOrCreateFederatedUser } from './getOrCreateFederatedUser';
jest.mock('@rocket.chat/models', () => ({
Users: {
findOneByUsername: jest.fn(),
},
}));
jest.mock('@rocket.chat/federation-sdk', () => ({
federationSDK: {
getConfig: jest.fn(),
getAppServiceForUser: jest.fn(),
},
}));
jest.mock('@rocket.chat/logger', () => ({
Logger: jest.fn().mockImplementation(() => ({
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
})),
}));
jest.mock('./createOrUpdateFederatedUser', () => ({
createOrUpdateFederatedUser: jest.fn(),
}));
const mockFindOneByUsername = Users.findOneByUsername as jest.MockedFunction<typeof Users.findOneByUsername>;
const mockGetConfig = federationSDK.getConfig as jest.MockedFunction<typeof federationSDK.getConfig>;
const mockGetAppServiceForUser = federationSDK.getAppServiceForUser as jest.MockedFunction<typeof federationSDK.getAppServiceForUser>;
const mockCreateOrUpdateFederatedUser = createOrUpdateFederatedUser as jest.MockedFunction<typeof createOrUpdateFederatedUser>;
// what createOrUpdateFederatedUser resolves to on first contact: a complete user document
const createdUser = {
_id: 'user123',
username: '@alice:example.com',
name: '@alice:example.com',
createdAt: new Date(),
_updatedAt: new Date(),
type: 'user',
active: true,
roles: ['federated-external'],
federated: true,
federation: { version: 1, mui: '@alice:example.com', origin: 'example.com' },
};
describe('getOrCreateFederatedUser', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetConfig.mockReturnValue('local.com' as any);
mockGetAppServiceForUser.mockReturnValue(undefined);
});
describe('when the user already exists locally (warm path)', () => {
it('should return the existing user without creating anything', async () => {
mockFindOneByUsername.mockResolvedValueOnce(createdUser as any);
const result = await getOrCreateFederatedUser('@alice:example.com');
expect(result).toBe(createdUser);
expect(mockCreateOrUpdateFederatedUser).not.toHaveBeenCalled();
});
it('should look the remote user up by the full Matrix ID', async () => {
mockFindOneByUsername.mockResolvedValueOnce(createdUser as any);
await getOrCreateFederatedUser('@alice:example.com');
expect(mockFindOneByUsername).toHaveBeenCalledWith('@alice:example.com');
});
it('should look a local user up by the localpart only', async () => {
mockFindOneByUsername.mockResolvedValueOnce({ ...createdUser, username: 'bob', name: 'Bob' } as any);
await getOrCreateFederatedUser('@bob:local.com');
expect(mockFindOneByUsername).toHaveBeenCalledWith('bob');
});
// local users are allowed to exist without a display name, so requiring one here would
// drop every membership event they take part in
it('should return a local user that has no name', async () => {
const namelessUser = { _id: 'user123', username: 'bob' };
mockFindOneByUsername.mockResolvedValueOnce(namelessUser as any);
await expect(getOrCreateFederatedUser('@bob:local.com')).resolves.toBe(namelessUser);
});
it('should throw when the existing user has no username', async () => {
mockFindOneByUsername.mockResolvedValueOnce({ _id: 'user123', name: 'Alice' } as any);
await expect(getOrCreateFederatedUser('@alice:example.com')).rejects.toThrow(
'Error getting or creating federated user @alice:example.com',
);
});
});
describe('when the remote user is unknown locally (cold path)', () => {
// the document itself is built by createOrUpdateFederatedUser, so its completeness is covered
// in createOrUpdateFederatedUser.spec.ts; all this path can show is that nothing is stripped
// off or rebuilt on the way out
it('should return the created document untouched', async () => {
mockFindOneByUsername.mockResolvedValueOnce(null);
mockCreateOrUpdateFederatedUser.mockResolvedValueOnce(createdUser as any);
const result = await getOrCreateFederatedUser('@alice:example.com');
expect(result).toBe(createdUser);
});
it('should create the user with the full Matrix ID and the origin server', async () => {
mockFindOneByUsername.mockResolvedValueOnce(null);
mockCreateOrUpdateFederatedUser.mockResolvedValueOnce(createdUser as any);
await getOrCreateFederatedUser('@alice:example.com');
expect(mockCreateOrUpdateFederatedUser).toHaveBeenCalledWith({
username: '@alice:example.com',
name: '@alice:example.com',
origin: 'example.com',
});
});
it('should not create anything for a malformed Matrix ID', async () => {
mockFindOneByUsername.mockResolvedValueOnce(null);
await expect(getOrCreateFederatedUser('@not a valid mxid:example.com')).rejects.toThrow(
'Error getting or creating federated user @not a valid mxid:example.com',
);
expect(mockCreateOrUpdateFederatedUser).not.toHaveBeenCalled();
});
it('should preserve the underlying failure as the error cause', async () => {
const cause = new Error('mongo is down');
mockFindOneByUsername.mockResolvedValueOnce(null);
mockCreateOrUpdateFederatedUser.mockRejectedValueOnce(cause);
await expect(getOrCreateFederatedUser('@alice:example.com')).rejects.toThrow(expect.objectContaining({ cause }) as unknown as Error);
});
});
describe('when the user is local', () => {
it('should throw instead of creating a federated user for an unknown local user', async () => {
mockFindOneByUsername.mockResolvedValueOnce(null);
await expect(getOrCreateFederatedUser('@bob:local.com')).rejects.toThrow('Error getting or creating federated user @bob:local.com');
expect(mockCreateOrUpdateFederatedUser).not.toHaveBeenCalled();
});
});
describe('when the user belongs to an application service', () => {
it('should return the appservice user looked up by full Matrix ID', async () => {
const appServiceUser = { ...createdUser, username: '@irc_bob:local.com', name: 'irc_bob' };
mockFindOneByUsername.mockResolvedValueOnce(null).mockResolvedValueOnce(appServiceUser as any);
mockGetAppServiceForUser.mockReturnValue({ registration: { _id: 'as1' } } as any);
const result = await getOrCreateFederatedUser('@irc_bob:local.com');
expect(result).toBe(appServiceUser);
expect(mockFindOneByUsername).toHaveBeenLastCalledWith('@irc_bob:local.com');
expect(mockCreateOrUpdateFederatedUser).not.toHaveBeenCalled();
});
it('should throw when the appservice user does not exist', async () => {
mockFindOneByUsername.mockResolvedValueOnce(null).mockResolvedValueOnce(null);
mockGetAppServiceForUser.mockReturnValue({ registration: { _id: 'as1' } } as any);
await expect(getOrCreateFederatedUser('@irc_bob:local.com')).rejects.toThrow(
'Error getting or creating federated user @irc_bob:local.com',
);
expect(mockCreateOrUpdateFederatedUser).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,71 @@
import type { IUser, RequiredField } from '@rocket.chat/core-typings';
import { federationSDK } from '@rocket.chat/federation-sdk';
import { Logger } from '@rocket.chat/logger';
import { Users } from '@rocket.chat/models';
import { createOrUpdateFederatedUser } from './createOrUpdateFederatedUser';
import { getUsernameServername } from './getUsernameServername';
import { validateFederatedUsername } from './validateFederatedUsername';
const logger = new Logger('federation-matrix:user');
/**
* `name` is deliberately not required: Rocket.Chat users are valid with a username and no
* display name (`Accounts_RequireNameForSignUp` off), so demanding it would drop membership
* events for nameless local users. Consumers must fall back to the username when displaying.
*/
type FederatedUser = RequiredField<IUser, 'username'>;
const hasUsername = (user: IUser): user is FederatedUser => user.username !== undefined;
/**
* Resolves the local user for a Matrix user id, creating it on first contact.
*
* Only the last branch creates anything: once a user exists locally every later call
* short-circuits on the lookup, which is why a broken creation path only ever surfaces
* on the very first interaction with a given remote user.
*/
export async function getOrCreateFederatedUser(userId: string): Promise<FederatedUser> {
try {
const serverName = federationSDK.getConfig('serverName');
const [username, userServerName, isLocal] = getUsernameServername(userId, serverName);
const user = await Users.findOneByUsername(username);
if (user) {
if (!hasUsername(user)) {
throw new Error(`User ${username} has no username for Matrix ID: ${userId}`);
}
return user;
}
const as = federationSDK.getAppServiceForUser(userId);
if (as) {
const user = await Users.findOneByUsername(userId);
if (!user) {
throw new Error('AppService user not found for creating user');
}
if (!hasUsername(user)) {
throw new Error(`AppService user ${userId} has no username`);
}
return user;
}
if (isLocal) {
throw new Error(`Local user ${username} not found for Matrix ID: ${userId}`);
}
if (!validateFederatedUsername(userId)) {
throw new Error(`Invalid federated Matrix ID: ${userId}`);
}
// awaited so a failure is logged with context and wrapped, instead of escaping the catch below
return await createOrUpdateFederatedUser({
username: userId,
name: userId,
origin: userServerName,
});
} catch (err) {
logger.error({ msg: 'Error getting or creating federated user', err, userId });
throw new Error(`Error getting or creating federated user ${userId}`, { cause: err });
}
}

View File

@@ -0,0 +1,17 @@
import { type EventID } from '@rocket.chat/federation-sdk';
import { Logger } from '@rocket.chat/logger';
import { Messages } from '@rocket.chat/models';
// TODO replace by a reusable logger
const logger = new Logger('federation-matrix:message');
export async function getThreadMessageId(threadRootEventId: EventID): Promise<{ tmid: string; tshow: boolean } | undefined> {
const threadRootMessage = await Messages.findOneByFederationId(threadRootEventId);
if (!threadRootMessage) {
logger.warn({ msg: 'Thread root message not found for event', eventId: threadRootEventId });
return;
}
const shouldSetTshow = !threadRootMessage?.tcount;
return { tmid: threadRootMessage._id, tshow: shouldSetTshow };
}

View File

@@ -0,0 +1,21 @@
import { extractDomainFromMatrixUserId } from './extractDomainFromMatrixUserId';
/**
* Extract the username and the servername from a matrix user id
* if the serverName is the same as the serverName in the mxid, return only the username (rocket.chat regular username)
* otherwise, return the full mxid and the servername
*/
export const getUsernameServername = (mxid: string, serverName: string): [mxid: string, serverName: string, isLocal: boolean] => {
const senderServerName = extractDomainFromMatrixUserId(mxid);
// if the serverName is the same as the serverName in the mxid, return only the username (rocket.chat regular username)
if (serverName === senderServerName) {
const separatorIndex = mxid.indexOf(':', 1);
if (separatorIndex === -1) {
throw new Error(`Invalid federated username: ${mxid}`);
}
return [mxid.substring(1, separatorIndex), senderServerName, true]; // removers also the @
}
return [mxid, senderServerName, false];
};

View File

@@ -0,0 +1,83 @@
import type { IUser, IRoom, FileAttachmentProps } from '@rocket.chat/core-typings';
import { type MessageType, type FileMessageContent, type EventID } from '@rocket.chat/federation-sdk';
import { MatrixMediaService } from '../services/MatrixMediaService';
export async function handleMediaMessage(
url: string,
fileInfo: FileMessageContent['info'],
msgtype: MessageType,
messageBody: string,
user: IUser,
room: IRoom,
matrixRoomId: string,
eventId: EventID,
thread?: { tmid: string; tshow: boolean },
): Promise<{
fromId: string;
rid: string;
msg: string;
federation_event_id: string;
thread?: { tmid: string; tshow: boolean };
attachments: [FileAttachmentProps];
}> {
const mimeType = fileInfo?.mimetype;
const fileName = messageBody;
const fileRefId = await MatrixMediaService.downloadAndStoreRemoteFile(url, matrixRoomId, {
name: messageBody,
size: fileInfo?.size || 0,
type: mimeType || 'application/octet-stream',
rid: room._id,
userId: user._id,
});
const fileUrl = `/file-upload/${fileRefId}/${encodeURIComponent(fileName)}`;
let attachment: FileAttachmentProps = {
title: fileName,
type: 'file',
title_link: fileUrl,
title_link_download: true,
description: '',
};
if (msgtype === 'm.image') {
attachment = {
...attachment,
image_url: fileUrl,
image_type: mimeType,
image_size: fileInfo?.size || 0,
...(fileInfo?.w &&
fileInfo?.h && {
image_dimensions: {
width: fileInfo.w,
height: fileInfo.h,
},
}),
};
} else if (msgtype === 'm.video') {
attachment = {
...attachment,
video_url: fileUrl,
video_type: mimeType,
video_size: fileInfo?.size || 0,
};
} else if (msgtype === 'm.audio') {
attachment = {
...attachment,
audio_url: fileUrl,
audio_type: mimeType,
audio_size: fileInfo?.size || 0,
};
}
return {
fromId: user._id,
rid: room._id,
msg: '',
federation_event_id: eventId,
thread,
attachments: [attachment],
};
}

View File

@@ -0,0 +1,103 @@
import { federationSDK } from '@rocket.chat/federation-sdk';
import { isReservedByExclusiveBridge } from './isReservedByExclusiveBridge';
jest.mock('@rocket.chat/federation-sdk', () => ({
federationSDK: {
getConfig: jest.fn(),
isExclusiveNamespace: jest.fn(),
},
}));
const mockGetConfig = federationSDK.getConfig as jest.MockedFunction<typeof federationSDK.getConfig>;
const mockIsExclusiveNamespace = federationSDK.isExclusiveNamespace as jest.MockedFunction<typeof federationSDK.isExclusiveNamespace>;
describe('isReservedByExclusiveBridge', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetConfig.mockReturnValue('example.com');
});
it('should return false when federation is not configured (no serverName)', () => {
mockGetConfig.mockReturnValue('');
expect(isReservedByExclusiveBridge('user', 'irc_bob')).toBe(false);
expect(isReservedByExclusiveBridge('room', 'irc_general')).toBe(false);
expect(mockIsExclusiveNamespace).not.toHaveBeenCalled();
});
describe('user', () => {
it('should return false when the username matches no exclusive namespace', () => {
mockIsExclusiveNamespace.mockReturnValue(undefined);
expect(isReservedByExclusiveBridge('user', 'alice')).toBe(false);
});
it('should check the `users` namespace with the full MXID', () => {
mockIsExclusiveNamespace.mockReturnValue(undefined);
isReservedByExclusiveBridge('user', 'alice');
expect(mockIsExclusiveNamespace).toHaveBeenCalledWith('users', '@alice:example.com');
});
it('should return true when the username falls within an exclusive namespace', () => {
mockIsExclusiveNamespace.mockImplementation((_type, value) =>
value === '@irc_bob:example.com' ? ({ registration: { _id: 'irc' } } as any) : undefined,
);
expect(isReservedByExclusiveBridge('user', 'irc_bob')).toBe(true);
});
it('should also match against the lowercased localpart', () => {
mockIsExclusiveNamespace.mockImplementation((_type, value) =>
value === '@irc_bob:example.com' ? ({ registration: { _id: 'irc' } } as any) : undefined,
);
expect(isReservedByExclusiveBridge('user', 'IRC_bob')).toBe(true);
});
});
describe('room', () => {
it('should check both the `aliases` and `rooms` namespaces with the room alias', () => {
mockIsExclusiveNamespace.mockReturnValue(undefined);
isReservedByExclusiveBridge('room', 'general');
expect(mockIsExclusiveNamespace).toHaveBeenCalledWith('aliases', '#general:example.com');
expect(mockIsExclusiveNamespace).toHaveBeenCalledWith('rooms', '#general:example.com');
});
it('should return true when the room name falls within the exclusive `aliases` namespace', () => {
mockIsExclusiveNamespace.mockImplementation((type, value) =>
type === 'aliases' && value === '#irc_general:example.com' ? ({ registration: { _id: 'irc' } } as any) : undefined,
);
expect(isReservedByExclusiveBridge('room', 'irc_general')).toBe(true);
});
it('should return true when the room name falls within the exclusive `rooms` namespace', () => {
mockIsExclusiveNamespace.mockImplementation((type, value) =>
type === 'rooms' && value === '#irc_general:example.com' ? ({ registration: { _id: 'irc' } } as any) : undefined,
);
expect(isReservedByExclusiveBridge('room', 'irc_general')).toBe(true);
});
it('should not check the `users` namespace for a room', () => {
mockIsExclusiveNamespace.mockReturnValue(undefined);
isReservedByExclusiveBridge('room', 'general');
expect(mockIsExclusiveNamespace).not.toHaveBeenCalledWith('users', expect.anything());
});
it('should also match against the lowercased localpart', () => {
mockIsExclusiveNamespace.mockImplementation((_type, value) =>
value === '#irc_general:example.com' ? ({ registration: { _id: 'irc' } } as any) : undefined,
);
expect(isReservedByExclusiveBridge('room', 'IRC_general')).toBe(true);
});
});
});

Some files were not shown because too many files have changed in this diff Show More