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
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:
487
ee/packages/abac/CHANGELOG.md
Normal file
487
ee/packages/abac/CHANGELOG.md
Normal 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>
|
||||
93
ee/packages/abac/docs/classification-banners.schema.json
Normal file
93
ee/packages/abac/docs/classification-banners.schema.json
Normal 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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
7
ee/packages/abac/jest.config.ts
Normal file
7
ee/packages/abac/jest.config.ts
Normal 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;
|
||||
49
ee/packages/abac/package.json
Normal file
49
ee/packages/abac/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
155
ee/packages/abac/src/audit.ts
Normal file
155
ee/packages/abac/src/audit.ts
Normal 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' });
|
||||
},
|
||||
};
|
||||
312
ee/packages/abac/src/can-access-object.spec.ts
Normal file
312
ee/packages/abac/src/can-access-object.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
90
ee/packages/abac/src/clients/virtru/VirtruClient.spec.ts
Normal file
90
ee/packages/abac/src/clients/virtru/VirtruClient.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
117
ee/packages/abac/src/clients/virtru/VirtruClient.ts
Normal file
117
ee/packages/abac/src/clients/virtru/VirtruClient.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
50
ee/packages/abac/src/clients/virtru/identity.spec.ts
Normal file
50
ee/packages/abac/src/clients/virtru/identity.spec.ts
Normal 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'] }]);
|
||||
});
|
||||
});
|
||||
48
ee/packages/abac/src/clients/virtru/identity.ts
Normal file
48
ee/packages/abac/src/clients/virtru/identity.ts
Normal 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 };
|
||||
}
|
||||
139
ee/packages/abac/src/errors.ts
Normal file
139
ee/packages/abac/src/errors.ts
Normal 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';
|
||||
};
|
||||
478
ee/packages/abac/src/helper.spec.ts
Normal file
478
ee/packages/abac/src/helper.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
320
ee/packages/abac/src/helper.ts
Normal file
320
ee/packages/abac/src/helper.ts
Normal 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(/\/+$/, '');
|
||||
989
ee/packages/abac/src/index.ts
Normal file
989
ee/packages/abac/src/index.ts
Normal 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;
|
||||
3
ee/packages/abac/src/logger.ts
Normal file
3
ee/packages/abac/src/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
|
||||
export const logger = new Logger('AbacService');
|
||||
106
ee/packages/abac/src/pdp/LocalPDP.ts
Normal file
106
ee/packages/abac/src/pdp/LocalPDP.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
668
ee/packages/abac/src/pdp/VirtruPDP.spec.ts
Normal file
668
ee/packages/abac/src/pdp/VirtruPDP.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
442
ee/packages/abac/src/pdp/VirtruPDP.ts
Normal file
442
ee/packages/abac/src/pdp/VirtruPDP.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
3
ee/packages/abac/src/pdp/index.ts
Normal file
3
ee/packages/abac/src/pdp/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { LocalPDP } from './LocalPDP';
|
||||
export { VirtruPDP } from './VirtruPDP';
|
||||
export type { IVirtruPDPConfig as VirtruPDPConfig, IPolicyDecisionPoint } from './types';
|
||||
97
ee/packages/abac/src/pdp/types.ts
Normal file
97
ee/packages/abac/src/pdp/types.ts
Normal 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[];
|
||||
}
|
||||
2032
ee/packages/abac/src/service.spec.ts
Normal file
2032
ee/packages/abac/src/service.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
54
ee/packages/abac/src/store/LocalAttributeStore.spec.ts
Normal file
54
ee/packages/abac/src/store/LocalAttributeStore.spec.ts
Normal 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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
58
ee/packages/abac/src/store/LocalAttributeStore.ts
Normal file
58
ee/packages/abac/src/store/LocalAttributeStore.ts
Normal 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
|
||||
}
|
||||
}
|
||||
301
ee/packages/abac/src/store/VirtruAttributeStore.spec.ts
Normal file
301
ee/packages/abac/src/store/VirtruAttributeStore.spec.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
187
ee/packages/abac/src/store/VirtruAttributeStore.ts
Normal file
187
ee/packages/abac/src/store/VirtruAttributeStore.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
3
ee/packages/abac/src/store/index.ts
Normal file
3
ee/packages/abac/src/store/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { LocalAttributeStore } from './LocalAttributeStore';
|
||||
export { VirtruAttributeStore } from './VirtruAttributeStore';
|
||||
export type { IAttributeStore, AttributeEntitlements, AttributeStoreDescriptor, AttributeStoreSelectionContext } from './types';
|
||||
38
ee/packages/abac/src/store/types.ts
Normal file
38
ee/packages/abac/src/store/types.ts
Normal 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;
|
||||
};
|
||||
545
ee/packages/abac/src/subject-attributes-validations.spec.ts
Normal file
545
ee/packages/abac/src/subject-attributes-validations.spec.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
94
ee/packages/abac/src/test-helpers/mongoMemoryServer.ts
Normal file
94
ee/packages/abac/src/test-helpers/mongoMemoryServer.ts
Normal 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();
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
592
ee/packages/abac/src/user-auto-removal.spec.ts
Normal file
592
ee/packages/abac/src/user-auto-removal.spec.ts
Normal 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']));
|
||||
});
|
||||
});
|
||||
});
|
||||
4
ee/packages/abac/tsconfig.build.json
Normal file
4
ee/packages/abac/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["./dist", "./src/**/*.spec.ts"]
|
||||
}
|
||||
10
ee/packages/abac/tsconfig.json
Normal file
10
ee/packages/abac/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@rocket.chat/tsconfig/server.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["./src/**/*"],
|
||||
"exclude": ["./dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user