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
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (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"]
}