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 2s
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 2s
This commit is contained in:
91
ee/apps/Dockerfile
Normal file
91
ee/apps/Dockerfile
Normal file
@@ -0,0 +1,91 @@
|
||||
FROM node:24.15.0-alpine3.23 AS builder
|
||||
|
||||
ARG SERVICE
|
||||
|
||||
RUN apk update && \
|
||||
apk --no-cache add g++ python3 make py3-setuptools
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# The build context is the pruned, self-contained subset produced by
|
||||
# `turbo prune <service> --use-gitignore=false` (then node_modules/.turbo stripped).
|
||||
# It already contains exactly this service's dependency closure: every in-scope
|
||||
# workspace's package.json + built dist (+ generated files like apps-engine/definition),
|
||||
# a pruned yarn.lock, and the workspace plumbing (.yarnrc.yml, .yarn/*).
|
||||
COPY . .
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# The service's own compiled output is emitted monorepo-mirrored inside its dist
|
||||
# (e.g. dist/ee/apps/<service>/src/service.js, dist/apps/meteor/... for cross-imports).
|
||||
# Overlay it onto /app so the compiled entrypoint lands at its real path, matching
|
||||
# `CMD node src/service.js` from the service workdir. Library packages keep their own
|
||||
# (flat) packages/*/dist, resolved via each package.json "main".
|
||||
RUN cp -a ee/apps/${SERVICE}/dist/. ./ && rm -rf ee/apps/${SERVICE}/dist
|
||||
|
||||
WORKDIR /app/ee/apps/${SERVICE}
|
||||
|
||||
RUN yarn workspaces focus --production
|
||||
|
||||
# `turbo prune` derives the closure from the *whole* workspace graph (incl. devDependencies)
|
||||
# and copies each in-scope package's full tree (TS sources, sourcemaps, type decls). None of
|
||||
# that is needed at runtime. Trim the build context to the production payload before it is
|
||||
# baked into the final image: drop staged workspace packages the production install did not
|
||||
# link (dev-only internal deps), library TS sources, sourcemaps, and type declarations.
|
||||
RUN set -eu; cd /app; \
|
||||
# Packages the production install linked (node-modules linker symlinks them under
|
||||
# node_modules/@rocket.chat). This is the source of truth for "needed at runtime".
|
||||
keep="$(find node_modules/@rocket.chat -maxdepth 1 -type l -exec readlink -f {} \; | sed 's#^/app/##')"; \
|
||||
# Guard: never proceed with an empty keep-set (e.g. a PnP linker with no symlinks),
|
||||
# which would otherwise delete every workspace package below.
|
||||
[ -n "$keep" ] || { echo 'trim: no linked workspace packages found — aborting'; exit 1; }; \
|
||||
for d in packages/* ee/packages/*; do \
|
||||
if printf '%s\n' "$keep" | grep -qxF "$d"; then \
|
||||
# Kept package: drop its TS sources only when it ships a dist/ (its runtime entry).
|
||||
# Packages whose entry point lives in src/ (no dist) keep their sources.
|
||||
[ -d "$d/dist" ] && rm -rf "$d/src" || true; \
|
||||
else \
|
||||
rm -rf "$d"; \
|
||||
fi; \
|
||||
done; \
|
||||
find packages ee/packages ee/apps \( -name '*.js.map' -o -name '*.d.ts' \) -delete
|
||||
|
||||
FROM node:24.15.0-alpine3.23 AS release-standard
|
||||
|
||||
ARG SERVICE
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000
|
||||
|
||||
RUN apk update && \
|
||||
apk --no-cache --virtual deps add shadow && \
|
||||
# Update OpenSSL
|
||||
# CVE -> https://scout.docker.com/vulnerabilities/id/CVE-2025-9230?s=alpine&n=openssl&ns=alpine&t=apk&osn=alpine&osv=3.22
|
||||
apk upgrade --no-cache openssl && \
|
||||
rm -rf /var/cache/apk/* && \
|
||||
groupmod -n rocketchat nogroup && \
|
||||
useradd -u 65533 -r -g rocketchat rocketchat && \
|
||||
apk del deps
|
||||
|
||||
COPY --chown=rocketchat:rocketchat --from=builder /app /app
|
||||
|
||||
WORKDIR /app/ee/apps/${SERVICE}
|
||||
|
||||
USER rocketchat
|
||||
|
||||
EXPOSE 3000 9458
|
||||
|
||||
CMD ["node", "src/service.js"]
|
||||
|
||||
FROM rocketchat/dhi-node:24.15.0-alpine3.23-fips AS release-fips
|
||||
ARG SERVICE
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000
|
||||
# the hardened image has no /etc/alpine-release, so node-gyp-build would detect
|
||||
# glibc and load the wrong prebuild for native modules
|
||||
ENV LIBC=musl
|
||||
COPY --chown=node:node --from=builder /app /app
|
||||
WORKDIR /app/ee/apps/${SERVICE}
|
||||
USER node
|
||||
EXPOSE 3000 9458
|
||||
CMD ["node", "--force-fips", "--require", "./src/fips.js", "src/service.js"]
|
||||
3973
ee/apps/account-service/CHANGELOG.md
Normal file
3973
ee/apps/account-service/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
57
ee/apps/account-service/package.json
Normal file
57
ee/apps/account-service/package.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "@rocket.chat/account-service",
|
||||
"version": "0.5.1",
|
||||
"private": true,
|
||||
"description": "Rocket.Chat Account service",
|
||||
"keywords": [
|
||||
"rocketchat"
|
||||
],
|
||||
"author": "Rocket.Chat",
|
||||
"main": "./dist/ee/apps/account-service/src/service.js",
|
||||
"files": [
|
||||
"/dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"lint": "eslint .",
|
||||
"ms": "TRANSPORTER=${TRANSPORTER:-TCP} MONGO_URL=${MONGO_URL:-mongodb://localhost:3001/meteor} ts-node --files src/service.ts",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rocket.chat/core-services": "workspace:^",
|
||||
"@rocket.chat/core-typings": "workspace:^",
|
||||
"@rocket.chat/emitter": "workspace:~",
|
||||
"@rocket.chat/logger": "workspace:^",
|
||||
"@rocket.chat/model-typings": "workspace:^",
|
||||
"@rocket.chat/models": "workspace:^",
|
||||
"@rocket.chat/network-broker": "workspace:^",
|
||||
"@rocket.chat/rest-typings": "workspace:^",
|
||||
"@rocket.chat/tools": "workspace:^",
|
||||
"@rocket.chat/tracing": "workspace:^",
|
||||
"@types/node": "~24.13.2",
|
||||
"bcrypt": "^6.0.0",
|
||||
"ejson": "^2.2.3",
|
||||
"eventemitter3": "^5.0.4",
|
||||
"mem": "^8.1.1",
|
||||
"moleculer": "^0.14.35",
|
||||
"mongodb": "6.16.0",
|
||||
"nats": "^2.28.2",
|
||||
"pino": "10.3.1",
|
||||
"polka": "^0.5.2",
|
||||
"prom-client": "^14.2.0",
|
||||
"prometheus-gc-stats": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rocket.chat/tsconfig": "workspace:*",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/polka": "^0.5.8",
|
||||
"@types/prometheus-gc-stats": "^0.6.4",
|
||||
"eslint": "~9.39.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"volta": {
|
||||
"extends": "../../../package.json"
|
||||
}
|
||||
}
|
||||
40
ee/apps/account-service/src/Account.ts
Normal file
40
ee/apps/account-service/src/Account.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ServiceClass, Settings } from '@rocket.chat/core-services';
|
||||
import type { IAccount, ILoginResult } from '@rocket.chat/core-services';
|
||||
import { getLoginExpirationInDays } from '@rocket.chat/tools';
|
||||
|
||||
import { loginViaResume } from './lib/loginViaResume';
|
||||
import { removeSession } from './lib/removeSession';
|
||||
|
||||
export class Account extends ServiceClass implements IAccount {
|
||||
protected name = 'accounts';
|
||||
|
||||
private loginExpiration = 90;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.onSettingChanged('Accounts_LoginExpiration', async ({ setting }): Promise<void> => {
|
||||
const { value } = setting;
|
||||
|
||||
this.loginExpiration = getLoginExpirationInDays(value as number);
|
||||
});
|
||||
}
|
||||
|
||||
async login({ resume }: { resume: string }): Promise<false | ILoginResult> {
|
||||
if (resume) {
|
||||
return loginViaResume(resume, this.loginExpiration);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async logout({ userId, token }: { userId: string; token: string }): Promise<void> {
|
||||
return removeSession(userId, token);
|
||||
}
|
||||
|
||||
override async started(): Promise<void> {
|
||||
const expiry = await Settings.get<number>('Accounts_LoginExpiration');
|
||||
|
||||
this.loginExpiration = getLoginExpirationInDays(expiry);
|
||||
}
|
||||
}
|
||||
9
ee/apps/account-service/src/fips.ts
Normal file
9
ee/apps/account-service/src/fips.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
crypto.setFips(true);
|
||||
|
||||
if (!crypto.getFips()) {
|
||||
throw new Error('FIPS mode was not enabled after crypto.setFips(true)');
|
||||
}
|
||||
|
||||
console.log('FIPS COMPLIANCE CHECK: YES');
|
||||
40
ee/apps/account-service/src/lib/loginViaResume.ts
Normal file
40
ee/apps/account-service/src/lib/loginViaResume.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { ILoginResult } from '@rocket.chat/core-services';
|
||||
import { MeteorError } from '@rocket.chat/core-services';
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
|
||||
import { _hashLoginToken, _tokenExpiration } from './utils';
|
||||
|
||||
export async function loginViaResume(resume: string, loginExpiration: number): Promise<false | ILoginResult> {
|
||||
const hashedToken = _hashLoginToken(resume);
|
||||
|
||||
const user = await Users.findOne<IUser>(
|
||||
{
|
||||
'services.resume.loginTokens.hashedToken': hashedToken,
|
||||
'active': true,
|
||||
},
|
||||
{
|
||||
projection: {
|
||||
'services.resume.loginTokens': 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const token = user.services?.resume?.loginTokens?.find((token) => token.hashedToken === hashedToken);
|
||||
|
||||
const tokenExpires = token && 'when' in token && _tokenExpiration(token.when, loginExpiration);
|
||||
if (tokenExpires && new Date() >= tokenExpires) {
|
||||
throw new MeteorError(403, 'Your session has expired. Please log in again.');
|
||||
}
|
||||
|
||||
return {
|
||||
uid: user._id,
|
||||
token: resume,
|
||||
hashedToken,
|
||||
type: 'resume',
|
||||
...(tokenExpires && { tokenExpires }),
|
||||
};
|
||||
}
|
||||
14
ee/apps/account-service/src/lib/removeSession.ts
Normal file
14
ee/apps/account-service/src/lib/removeSession.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Users } from '@rocket.chat/models';
|
||||
|
||||
export async function removeSession(uid: string, loginToken: string): Promise<void> {
|
||||
await Users.updateOne(
|
||||
{ _id: uid },
|
||||
{
|
||||
$pull: {
|
||||
'services.resume.loginTokens': {
|
||||
$or: [{ hashedToken: loginToken }, { token: loginToken }],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
14
ee/apps/account-service/src/lib/saveSession.ts
Normal file
14
ee/apps/account-service/src/lib/saveSession.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Users } from '@rocket.chat/models';
|
||||
|
||||
import type { IHashedStampedToken } from './utils';
|
||||
|
||||
export async function saveSession(uid: string, newToken: IHashedStampedToken): Promise<void> {
|
||||
await Users.updateOne(
|
||||
{ _id: uid },
|
||||
{
|
||||
$push: {
|
||||
'services.resume.loginTokens': newToken.hashedToken,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
63
ee/apps/account-service/src/lib/utils.ts
Normal file
63
ee/apps/account-service/src/lib/utils.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { convertFromDaysToMilliseconds } from '@rocket.chat/tools';
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
export interface IStampedToken {
|
||||
token: string;
|
||||
when: Date;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface IHashedStampedToken {
|
||||
when: Date;
|
||||
hashedToken: string;
|
||||
}
|
||||
|
||||
type Password =
|
||||
| string
|
||||
| {
|
||||
digest: string;
|
||||
};
|
||||
|
||||
export const getPassword = (password: Password): string => {
|
||||
if (typeof password === 'string') {
|
||||
return crypto.createHash('sha256').update(password).digest('hex'); // lgtm [js/insufficient-password-hash]
|
||||
}
|
||||
if (typeof password.digest === 'undefined') {
|
||||
throw new Error('invalid password');
|
||||
}
|
||||
return password.digest;
|
||||
};
|
||||
|
||||
// https://github.com/meteor/meteor/blob/c5b51b0fc2a8cef498b9390ebcb4925e02de83e8/packages/accounts-base/accounts_server.js#L934
|
||||
export const _generateStampedLoginToken = (): IStampedToken => ({
|
||||
token: crypto.randomUUID(),
|
||||
when: new Date(),
|
||||
});
|
||||
|
||||
// https://github.com/meteor/meteor/blob/c5b51b0fc2a8cef498b9390ebcb4925e02de83e8/packages/accounts-base/accounts_server.js#L780
|
||||
export const _hashLoginToken = (loginToken: string): string => {
|
||||
const hash = crypto.createHash('sha256');
|
||||
hash.update(loginToken);
|
||||
return hash.digest('base64');
|
||||
};
|
||||
|
||||
// https://github.com/meteor/meteor/blob/c5b51b0fc2a8cef498b9390ebcb4925e02de83e8/packages/accounts-base/accounts_server.js#L787
|
||||
export const _hashStampedToken = (stampedToken: IStampedToken): IHashedStampedToken => {
|
||||
const hashedStampedToken = Object.keys(stampedToken).reduce(
|
||||
(prev, key) => (key === 'token' ? prev : { ...prev, [key]: stampedToken[key] }),
|
||||
{},
|
||||
);
|
||||
|
||||
return {
|
||||
...hashedStampedToken,
|
||||
hashedToken: _hashLoginToken(stampedToken.token),
|
||||
} as IHashedStampedToken;
|
||||
};
|
||||
|
||||
export const validatePassword = (password: string, bcryptPassword: string): Promise<boolean> =>
|
||||
bcrypt.compare(getPassword(password), bcryptPassword);
|
||||
|
||||
export const _tokenExpiration = (when: string | Date, expirationInDays: number): Date =>
|
||||
new Date(new Date(when).getTime() + convertFromDaysToMilliseconds(expirationInDays));
|
||||
38
ee/apps/account-service/src/service.ts
Executable file
38
ee/apps/account-service/src/service.ts
Executable file
@@ -0,0 +1,38 @@
|
||||
import { api, getConnection, getTrashCollection } from '@rocket.chat/core-services';
|
||||
import { registerServiceModels } from '@rocket.chat/models';
|
||||
import { startBroker } from '@rocket.chat/network-broker';
|
||||
import { startTracing } from '@rocket.chat/tracing';
|
||||
import polka from 'polka';
|
||||
|
||||
const PORT = process.env.PORT || 3033;
|
||||
|
||||
void (async () => {
|
||||
const { db, client } = await getConnection();
|
||||
|
||||
startTracing({ service: 'account-service', db: client });
|
||||
|
||||
registerServiceModels(db, await getTrashCollection());
|
||||
|
||||
api.setBroker(startBroker());
|
||||
|
||||
// need to import service after models are registered
|
||||
const { Account } = await import('./Account');
|
||||
|
||||
api.registerService(new Account());
|
||||
|
||||
await api.start();
|
||||
|
||||
polka()
|
||||
.get('/health', async function (_req, res) {
|
||||
try {
|
||||
await api.nodeList();
|
||||
res.end('ok');
|
||||
} catch (err) {
|
||||
console.error('Service not healthy', err);
|
||||
|
||||
res.writeHead(500);
|
||||
res.end('not healthy');
|
||||
}
|
||||
})
|
||||
.listen(PORT);
|
||||
})();
|
||||
19
ee/apps/account-service/tsconfig.json
Normal file
19
ee/apps/account-service/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "@rocket.chat/tsconfig/server.json",
|
||||
"compilerOptions": {
|
||||
"incremental": true,
|
||||
|
||||
/* Strict Type-Checking Options */
|
||||
"strictPropertyInitialization": false,
|
||||
"strictFunctionTypes": false,
|
||||
|
||||
/* Additional Checks */
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
|
||||
"outDir": "./dist/ee/apps/account-service/src",
|
||||
},
|
||||
"include":
|
||||
["./src/**/*.ts",
|
||||
"../../../apps/meteor/definition/externals/meteor"],
|
||||
"exclude": ["./dist"]
|
||||
}
|
||||
4008
ee/apps/authorization-service/CHANGELOG.md
Normal file
4008
ee/apps/authorization-service/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
55
ee/apps/authorization-service/package.json
Normal file
55
ee/apps/authorization-service/package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "@rocket.chat/authorization-service",
|
||||
"version": "0.7.1",
|
||||
"private": true,
|
||||
"description": "Rocket.Chat Authorization service",
|
||||
"keywords": [
|
||||
"rocketchat"
|
||||
],
|
||||
"author": "Rocket.Chat",
|
||||
"main": "./dist/ee/apps/authorization-service/src/service.js",
|
||||
"files": [
|
||||
"/dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"lint": "eslint .",
|
||||
"ms": "TRANSPORTER=${TRANSPORTER:-TCP} MONGO_URL=${MONGO_URL:-mongodb://localhost:3001/meteor} ts-node --files src/service.ts",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rocket.chat/abac": "workspace:^",
|
||||
"@rocket.chat/core-services": "workspace:^",
|
||||
"@rocket.chat/core-typings": "workspace:^",
|
||||
"@rocket.chat/emitter": "workspace:~",
|
||||
"@rocket.chat/logger": "workspace:^",
|
||||
"@rocket.chat/model-typings": "workspace:^",
|
||||
"@rocket.chat/models": "workspace:^",
|
||||
"@rocket.chat/network-broker": "workspace:^",
|
||||
"@rocket.chat/rest-typings": "workspace:^",
|
||||
"@rocket.chat/tracing": "workspace:^",
|
||||
"@types/node": "~24.13.2",
|
||||
"ejson": "^2.2.3",
|
||||
"eventemitter3": "^5.0.4",
|
||||
"mem": "^8.1.1",
|
||||
"moleculer": "^0.14.35",
|
||||
"mongodb": "6.16.0",
|
||||
"nats": "^2.28.2",
|
||||
"pino": "10.3.1",
|
||||
"polka": "^0.5.2",
|
||||
"prom-client": "^14.2.0",
|
||||
"prometheus-gc-stats": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rocket.chat/tsconfig": "workspace:*",
|
||||
"@types/polka": "^0.5.8",
|
||||
"@types/prometheus-gc-stats": "^0.6.4",
|
||||
"eslint": "~9.39.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"volta": {
|
||||
"extends": "../../../package.json"
|
||||
}
|
||||
}
|
||||
9
ee/apps/authorization-service/src/fips.ts
Normal file
9
ee/apps/authorization-service/src/fips.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
crypto.setFips(true);
|
||||
|
||||
if (!crypto.getFips()) {
|
||||
throw new Error('FIPS mode was not enabled after crypto.setFips(true)');
|
||||
}
|
||||
|
||||
console.log('FIPS COMPLIANCE CHECK: YES');
|
||||
44
ee/apps/authorization-service/src/service.ts
Executable file
44
ee/apps/authorization-service/src/service.ts
Executable file
@@ -0,0 +1,44 @@
|
||||
import { AbacService } from '@rocket.chat/abac';
|
||||
import { api, getConnection, getTrashCollection } from '@rocket.chat/core-services';
|
||||
import { registerServiceModels } from '@rocket.chat/models';
|
||||
import { startBroker } from '@rocket.chat/network-broker';
|
||||
import { startTracing } from '@rocket.chat/tracing';
|
||||
import polka from 'polka';
|
||||
|
||||
const PORT = process.env.PORT || 3034;
|
||||
|
||||
void (async () => {
|
||||
const { db, client } = await getConnection();
|
||||
|
||||
startTracing({ service: 'authorization-service', db: client });
|
||||
|
||||
registerServiceModels(db, await getTrashCollection());
|
||||
|
||||
api.setBroker(startBroker());
|
||||
|
||||
// need to import service after models are registered
|
||||
const { Authorization } = await import('../../../../apps/meteor/server/services/authorization/service');
|
||||
|
||||
api.registerService(new Authorization());
|
||||
|
||||
if (!process.env.USE_EXTERNAL_ABAC_SERVICE) {
|
||||
// Same API as authz service but own core-services proxy
|
||||
api.registerService(new AbacService());
|
||||
}
|
||||
|
||||
await api.start();
|
||||
|
||||
polka()
|
||||
.get('/health', async function (_req, res) {
|
||||
try {
|
||||
await api.nodeList();
|
||||
res.end('ok');
|
||||
} catch (err) {
|
||||
console.error('Service not healthy', err);
|
||||
|
||||
res.writeHead(500);
|
||||
res.end('not healthy');
|
||||
}
|
||||
})
|
||||
.listen(PORT);
|
||||
})();
|
||||
10
ee/apps/authorization-service/tsconfig.json
Normal file
10
ee/apps/authorization-service/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@rocket.chat/tsconfig/server.json",
|
||||
"compilerOptions": {
|
||||
"strictPropertyInitialization": false, // TODO: Remove this line
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"files": ["./src/service.ts", "./src/fips.ts"],
|
||||
"include": ["../../../apps/meteor/definition/externals/meteor"],
|
||||
"exclude": ["./dist"]
|
||||
}
|
||||
4433
ee/apps/ddp-streamer/CHANGELOG.md
Normal file
4433
ee/apps/ddp-streamer/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
16
ee/apps/ddp-streamer/definition/externals/ws.d.ts
vendored
Normal file
16
ee/apps/ddp-streamer/definition/externals/ws.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'ws';
|
||||
|
||||
declare module 'ws' {
|
||||
namespace Sender {
|
||||
function frame(
|
||||
data: Buffer,
|
||||
options: {
|
||||
opcode: number; // The opcode
|
||||
readOnly: boolean; // Specifies whether `data` can be modified
|
||||
fin: boolean; // Specifies whether or not to set the FIN bit
|
||||
mask: boolean; // Specifies whether or not to mask `data`
|
||||
rsv1: boolean; // Specifies whether or not to set the RSV1 bit
|
||||
},
|
||||
): Buffer[];
|
||||
}
|
||||
}
|
||||
7
ee/apps/ddp-streamer/jest.config.ts
Normal file
7
ee/apps/ddp-streamer/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;
|
||||
71
ee/apps/ddp-streamer/package.json
Normal file
71
ee/apps/ddp-streamer/package.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"name": "@rocket.chat/ddp-streamer",
|
||||
"version": "0.4.1",
|
||||
"private": true,
|
||||
"description": "Rocket.Chat DDP-Streamer service",
|
||||
"keywords": [
|
||||
"rocketchat"
|
||||
],
|
||||
"author": "Rocket.Chat",
|
||||
"main": "./dist/service.js",
|
||||
"files": [
|
||||
"/dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"lint": "eslint .",
|
||||
"ms": "TRANSPORTER=${TRANSPORTER:-TCP} MONGO_URL=${MONGO_URL:-mongodb://localhost:3001/meteor} ts-node --files src/service.ts",
|
||||
"test": "jest",
|
||||
"testunit": "jest",
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rocket.chat/core-services": "workspace:^",
|
||||
"@rocket.chat/core-typings": "workspace:^",
|
||||
"@rocket.chat/emitter": "workspace:~",
|
||||
"@rocket.chat/instance-status": "workspace:^",
|
||||
"@rocket.chat/logger": "workspace:^",
|
||||
"@rocket.chat/model-typings": "workspace:^",
|
||||
"@rocket.chat/models": "workspace:^",
|
||||
"@rocket.chat/network-broker": "workspace:^",
|
||||
"@rocket.chat/rest-typings": "workspace:^",
|
||||
"@rocket.chat/tracing": "workspace:^",
|
||||
"colorette": "^1.4.0",
|
||||
"ejson": "^2.2.3",
|
||||
"eventemitter3": "^5.0.4",
|
||||
"jaeger-client": "^3.19.0",
|
||||
"mem": "^8.1.1",
|
||||
"moleculer": "^0.14.35",
|
||||
"mongodb": "6.16.0",
|
||||
"nats": "^2.28.2",
|
||||
"pino": "10.3.1",
|
||||
"polka": "^0.5.2",
|
||||
"prom-client": "^14.2.0",
|
||||
"prometheus-gc-stats": "^1.1.0",
|
||||
"sharp": "^0.35.4",
|
||||
"underscore": "^1.13.8",
|
||||
"uuid": "^11.1.1",
|
||||
"ws": "~8.21.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rocket.chat/apps-engine": "workspace:^",
|
||||
"@rocket.chat/ddp-client": "workspace:~",
|
||||
"@rocket.chat/jest-presets": "workspace:*",
|
||||
"@types/ejson": "^2.2.2",
|
||||
"@types/jest": "~30.0.0",
|
||||
"@types/node": "~24.13.2",
|
||||
"@types/polka": "^0.5.8",
|
||||
"@types/prometheus-gc-stats": "^0.6.4",
|
||||
"@types/underscore": "^1.13.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"eslint": "~9.39.5",
|
||||
"jest": "~30.2.0",
|
||||
"pino-pretty": "13.1.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"volta": {
|
||||
"extends": "../../../package.json"
|
||||
}
|
||||
}
|
||||
239
ee/apps/ddp-streamer/src/Client.ts
Normal file
239
ee/apps/ddp-streamer/src/Client.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import type { IncomingMessage } from 'http';
|
||||
|
||||
import { Presence } from '@rocket.chat/core-services';
|
||||
import type { ISocketConnection } from '@rocket.chat/core-typings';
|
||||
import { throttle } from 'underscore';
|
||||
import { v1 as uuidv1 } from 'uuid';
|
||||
import type WebSocket from 'ws';
|
||||
|
||||
import { SERVER_ID } from './Server';
|
||||
import { server } from './configureServer';
|
||||
import { DDP_EVENTS, WS_ERRORS, WS_ERRORS_MESSAGES, TIMEOUT } from './constants';
|
||||
import type { IPacket } from './types/IPacket';
|
||||
|
||||
// TODO why localhost not as 127.0.0.1?
|
||||
// based on Meteor's implementation (link)
|
||||
const getClientAddress = (req: IncomingMessage): string | undefined => {
|
||||
// For the reported client address for a connection to be correct,
|
||||
// the developer must set the HTTP_FORWARDED_COUNT environment
|
||||
// variable to an integer representing the number of hops they
|
||||
// expect in the `x-forwarded-for` header. E.g., set to "1" if the
|
||||
// server is behind one proxy.
|
||||
//
|
||||
// This could be computed once at startup instead of every time.
|
||||
const httpForwardedCount = parseInt(process.env.HTTP_FORWARDED_COUNT || '') || 0;
|
||||
|
||||
if (httpForwardedCount === 0) {
|
||||
return req.socket.remoteAddress;
|
||||
}
|
||||
|
||||
const forwardedFor =
|
||||
(req.headers['x-forwarded-for'] && Array.isArray(req.headers['x-forwarded-for'])
|
||||
? req.headers['x-forwarded-for'][0]
|
||||
: req.headers['x-forwarded-for']) || '';
|
||||
if (!forwardedFor) {
|
||||
return;
|
||||
}
|
||||
const forwardedForClean = forwardedFor
|
||||
.trim()
|
||||
.split(',')
|
||||
.map((ip) => ip.trim());
|
||||
|
||||
// Typically the first value in the `x-forwarded-for` header is
|
||||
// the original IP address of the client connecting to the first
|
||||
// proxy. However, the end user can easily spoof the header, in
|
||||
// which case the first value(s) will be the fake IP address from
|
||||
// the user pretending to be a proxy reporting the original IP
|
||||
// address value. By counting HTTP_FORWARDED_COUNT back from the
|
||||
// end of the list, we ensure that we get the IP address being
|
||||
// reported by *our* first proxy.
|
||||
|
||||
if (httpForwardedCount < 0 || httpForwardedCount > forwardedForClean.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return forwardedForClean[forwardedForClean.length - httpForwardedCount];
|
||||
};
|
||||
|
||||
export const clientMap = new WeakMap<WebSocket, Client>();
|
||||
|
||||
export class Client extends EventEmitter {
|
||||
private chain = Promise.resolve();
|
||||
|
||||
protected timeout: NodeJS.Timeout;
|
||||
|
||||
public readonly session = uuidv1();
|
||||
|
||||
public subscriptions = new Map();
|
||||
|
||||
public connection: ISocketConnection;
|
||||
|
||||
public wait = false;
|
||||
|
||||
public userId?: string;
|
||||
|
||||
public userToken?: string;
|
||||
|
||||
private updatePresence = throttle(
|
||||
() => {
|
||||
if (this.userId) {
|
||||
void Presence.updateConnection(this.userId, this.connection.id).catch((err) => {
|
||||
console.error('Error updating connection presence:', err);
|
||||
});
|
||||
}
|
||||
},
|
||||
TIMEOUT,
|
||||
{ leading: true, trailing: false },
|
||||
);
|
||||
|
||||
constructor(
|
||||
public ws: WebSocket,
|
||||
public meteorClient = false,
|
||||
req: IncomingMessage,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.connection = {
|
||||
id: this.session,
|
||||
instanceId: server.id,
|
||||
onClose: (fn): void => {
|
||||
this.on('close', fn);
|
||||
},
|
||||
clientAddress: getClientAddress(req),
|
||||
httpHeaders: req.headers,
|
||||
};
|
||||
|
||||
this.renewTimeout(TIMEOUT / 1000);
|
||||
this.ws.on('message', this.handler);
|
||||
this.ws.on('close', (...args) => {
|
||||
server.emit(DDP_EVENTS.DISCONNECTED, this);
|
||||
this.emit('close', ...args);
|
||||
this.subscriptions.clear();
|
||||
clearTimeout(this.timeout);
|
||||
});
|
||||
|
||||
this.ws.on('error', (err) => {
|
||||
console.error('Unexpected error:', err);
|
||||
this.ws.close(WS_ERRORS.CLOSE_PROTOCOL_ERROR, WS_ERRORS_MESSAGES.CLOSE_PROTOCOL_ERROR);
|
||||
});
|
||||
|
||||
this.setMaxListeners(50);
|
||||
|
||||
this.greeting();
|
||||
|
||||
server.emit(DDP_EVENTS.CONNECTED, this);
|
||||
|
||||
this.ws.on('message', () => this.renewTimeout(TIMEOUT));
|
||||
|
||||
this.once('message', ({ msg }) => {
|
||||
if (msg !== DDP_EVENTS.CONNECT) {
|
||||
return this.ws.close(WS_ERRORS.CLOSE_PROTOCOL_ERROR, WS_ERRORS_MESSAGES.CLOSE_PROTOCOL_ERROR);
|
||||
}
|
||||
return this.send(server.serialize({ [DDP_EVENTS.MSG]: DDP_EVENTS.CONNECTED, session: this.session }));
|
||||
});
|
||||
|
||||
this.send(SERVER_ID);
|
||||
|
||||
clientMap.set(ws, this);
|
||||
}
|
||||
|
||||
greeting(): void {
|
||||
// no greeting by default
|
||||
if (this.meteorClient) {
|
||||
return this.ws.send('o');
|
||||
}
|
||||
}
|
||||
|
||||
async callMethod(packet: IPacket): Promise<void> {
|
||||
this.chain = this.chain.then(() => server.call(this, packet)).catch();
|
||||
}
|
||||
|
||||
async callSubscribe(packet: IPacket): Promise<void> {
|
||||
this.chain = this.chain.then(() => server.subscribe(this, packet)).catch();
|
||||
}
|
||||
|
||||
process(action: string, packet: IPacket): void {
|
||||
switch (action) {
|
||||
case DDP_EVENTS.PING:
|
||||
this.pong(packet.id);
|
||||
break;
|
||||
case DDP_EVENTS.METHOD:
|
||||
if (!packet.method) {
|
||||
return this.ws.close(WS_ERRORS.CLOSE_PROTOCOL_ERROR);
|
||||
}
|
||||
if (!packet.id) {
|
||||
return this.ws.close(WS_ERRORS.CLOSE_PROTOCOL_ERROR);
|
||||
}
|
||||
void this.callMethod(packet);
|
||||
break;
|
||||
case DDP_EVENTS.SUBSCRIBE:
|
||||
if (!packet.name) {
|
||||
return this.ws.close(WS_ERRORS.CLOSE_PROTOCOL_ERROR);
|
||||
}
|
||||
if (!packet.id) {
|
||||
return this.ws.close(WS_ERRORS.CLOSE_PROTOCOL_ERROR);
|
||||
}
|
||||
void this.callSubscribe(packet);
|
||||
break;
|
||||
case DDP_EVENTS.UNSUBSCRIBE:
|
||||
if (!packet.id) {
|
||||
return this.ws.close(WS_ERRORS.CLOSE_PROTOCOL_ERROR);
|
||||
}
|
||||
const subscription = this.subscriptions.get(packet.id);
|
||||
if (!subscription) {
|
||||
return;
|
||||
}
|
||||
subscription.stop();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
closeTimeout = (): void => {
|
||||
this.ws.close(WS_ERRORS.TIMEOUT, WS_ERRORS_MESSAGES.TIMEOUT);
|
||||
};
|
||||
|
||||
ping(id?: string): void {
|
||||
this.send(server.serialize({ [DDP_EVENTS.MSG]: DDP_EVENTS.PING, ...(id && { [DDP_EVENTS.ID]: id }) }));
|
||||
}
|
||||
|
||||
pong(id?: string): void {
|
||||
this.send(server.serialize({ [DDP_EVENTS.MSG]: DDP_EVENTS.PONG, ...(id && { [DDP_EVENTS.ID]: id }) }));
|
||||
}
|
||||
|
||||
handleIdle = (): void => {
|
||||
this.ping();
|
||||
this.timeout = setTimeout(this.closeTimeout, TIMEOUT);
|
||||
};
|
||||
|
||||
renewTimeout(timeout = TIMEOUT): void {
|
||||
clearTimeout(this.timeout);
|
||||
this.timeout = setTimeout(this.handleIdle, timeout);
|
||||
}
|
||||
|
||||
handler = async (payload: WebSocket.Data, isBinary: boolean): Promise<void> => {
|
||||
try {
|
||||
const packet = server.parse(payload, isBinary);
|
||||
this.updatePresence();
|
||||
this.emit('message', packet);
|
||||
if (this.wait) {
|
||||
return new Promise((resolve) => this.once(DDP_EVENTS.LOGGED, () => resolve(this.process(packet.msg, packet))));
|
||||
}
|
||||
this.process(packet.msg, packet);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return this.ws.close(WS_ERRORS.UNSUPPORTED_DATA, WS_ERRORS_MESSAGES.UNSUPPORTED_DATA);
|
||||
}
|
||||
};
|
||||
|
||||
encodePayload(payload: string): string {
|
||||
if (this.meteorClient) {
|
||||
return `a${JSON.stringify([payload])}`;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
send(payload: string): void {
|
||||
return this.ws.send(this.encodePayload(payload));
|
||||
}
|
||||
}
|
||||
308
ee/apps/ddp-streamer/src/DDPStreamer.ts
Normal file
308
ee/apps/ddp-streamer/src/DDPStreamer.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { MeteorService, Presence, ServiceClass } from '@rocket.chat/core-services';
|
||||
import { InstanceStatus } from '@rocket.chat/instance-status';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import polka from 'polka';
|
||||
import { throttle } from 'underscore';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
import { Client, clientMap } from './Client';
|
||||
import { events, server } from './configureServer';
|
||||
import { DDP_EVENTS } from './constants';
|
||||
import { Autoupdate } from './lib/Autoupdate';
|
||||
import { proxy } from './proxy';
|
||||
import { ListenersModule } from '../../../../apps/meteor/server/modules/listeners/listeners.module';
|
||||
import type { NotificationsModule } from '../../../../apps/meteor/server/modules/notifications/notifications.module';
|
||||
import { invalidate as invalidatePublicationUserCache } from '../../../../apps/meteor/server/modules/streamer/publication-user-cache';
|
||||
import { StreamerCentral } from '../../../../apps/meteor/server/modules/streamer/streamer.module';
|
||||
|
||||
const { PORT = 4000 } = process.env;
|
||||
|
||||
export class DDPStreamer extends ServiceClass {
|
||||
protected name = 'streamer';
|
||||
|
||||
private app?: polka.Polka;
|
||||
|
||||
private wss?: WebSocket.Server;
|
||||
|
||||
constructor(notifications: NotificationsModule) {
|
||||
super();
|
||||
|
||||
new ListenersModule(this, notifications);
|
||||
|
||||
// TODO this is triggered by local events too, need to find a way to ignore if it's local
|
||||
this.onEvent('stream', ([streamer, eventName, args]): void => {
|
||||
// TODO rename StreamerCentral to StreamerStore or something to use it only as a store
|
||||
const stream = StreamerCentral.instances[streamer];
|
||||
return stream?.emitWithoutBroadcast(eventName, ...args);
|
||||
});
|
||||
|
||||
this.onEvent('watch.loginServiceConfiguration', ({ clientAction, id, data }) => {
|
||||
if (clientAction === 'removed') {
|
||||
events.emit('meteor.loginServiceConfiguration', 'removed', {
|
||||
_id: id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
events.emit('meteor.loginServiceConfiguration', clientAction === 'inserted' ? 'added' : 'changed', data);
|
||||
}
|
||||
});
|
||||
|
||||
this.onEvent('user.forceLogout', (uid: string, sessionId?: string) => {
|
||||
this.wss?.clients.forEach((ws) => {
|
||||
const client = clientMap.get(ws);
|
||||
if (sessionId) {
|
||||
if (client?.connection.id === sessionId) {
|
||||
ws.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (client?.userId === uid) {
|
||||
// Graceful close: lets the WS lib flush queued frames (including
|
||||
// the `notify-user/<uid>/force_logout` stream message that the
|
||||
// monolith listener at apps/meteor/server/modules/listeners/listeners.module.ts:49
|
||||
// just enqueued) before the socket goes down. Previously this was
|
||||
// `ws.terminate()`, which sends a TCP RST immediately and drops
|
||||
// the queued frames — clients depending on the stream message
|
||||
// (useForceLogout hook → Accounts._unstoreLoginToken + setUserId(null))
|
||||
// then never see the cleanup, leaving stale credentials in
|
||||
// localStorage. Falls back to terminate() after a short grace
|
||||
// period for unresponsive sockets.
|
||||
ws.close();
|
||||
const guard = setTimeout(() => {
|
||||
if (ws.readyState !== ws.CLOSED) {
|
||||
ws.terminate();
|
||||
}
|
||||
}, 5000);
|
||||
ws.once('close', () => clearTimeout(guard));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.onEvent('meteor.clientVersionUpdated', (versions): void => {
|
||||
Autoupdate.updateVersion(versions);
|
||||
});
|
||||
|
||||
// The publication user cache lives inside the NotificationsModule process. In a
|
||||
// microservices deployment, ddp-streamer hosts that module but does not host
|
||||
// MeteorService, so the watch.users invalidation registered in
|
||||
// apps/meteor/server/services/meteor/service.ts never reaches this process.
|
||||
// Without this listener, role/ban/user changes would only propagate after the
|
||||
// 60s TTL expires.
|
||||
this.onEvent('watch.users', (data): void => {
|
||||
invalidatePublicationUserCache(data.id);
|
||||
});
|
||||
}
|
||||
|
||||
// update connections count every 30 seconds
|
||||
updateConnections = throttle(() => {
|
||||
void InstanceStatus.updateConnections(this.wss?.clients.size ?? 0);
|
||||
}, 30000);
|
||||
|
||||
override async created(): Promise<void> {
|
||||
if (!this.context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { broker, nodeID } = this.context;
|
||||
if (!broker || !nodeID) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { metrics } = broker;
|
||||
if (!metrics) {
|
||||
return;
|
||||
}
|
||||
|
||||
metrics.register({
|
||||
name: 'rocketchat_subscription',
|
||||
type: 'histogram',
|
||||
labelNames: ['subscription'],
|
||||
description: 'Client subscriptions to Rocket.Chat',
|
||||
unit: 'millisecond',
|
||||
quantiles: true,
|
||||
});
|
||||
|
||||
metrics.register({
|
||||
name: 'users_connected',
|
||||
type: 'gauge',
|
||||
labelNames: ['nodeID'],
|
||||
description: 'Users connected by streamer',
|
||||
});
|
||||
|
||||
metrics.register({
|
||||
name: 'users_logged',
|
||||
type: 'gauge',
|
||||
labelNames: ['nodeID'],
|
||||
description: 'Users logged by streamer',
|
||||
});
|
||||
|
||||
server.setMetrics(metrics);
|
||||
|
||||
server.on(DDP_EVENTS.CONNECTED, () => {
|
||||
metrics.increment('users_connected', { nodeID }, 1);
|
||||
});
|
||||
|
||||
server.on(DDP_EVENTS.LOGGED, () => {
|
||||
metrics.increment('users_logged', { nodeID }, 1);
|
||||
});
|
||||
|
||||
server.on(DDP_EVENTS.DISCONNECTED, ({ userId }) => {
|
||||
metrics.decrement('users_connected', { nodeID }, 1);
|
||||
if (userId) {
|
||||
metrics.decrement('users_logged', { nodeID }, 1);
|
||||
}
|
||||
});
|
||||
|
||||
async function sendUserData(client: Client, userId: string) {
|
||||
// TODO figure out what fields to send. maybe to to export function getBaseUserFields to a package
|
||||
const loggedUser = await Users.findOneById(userId, {
|
||||
projection: {
|
||||
'name': 1,
|
||||
'username': 1,
|
||||
'nickname': 1,
|
||||
'emails': 1,
|
||||
'status': 1,
|
||||
'statusDefault': 1,
|
||||
'statusText': 1,
|
||||
'statusConnection': 1,
|
||||
'bio': 1,
|
||||
'avatarOrigin': 1,
|
||||
'utcOffset': 1,
|
||||
'language': 1,
|
||||
'settings': 1,
|
||||
'enableAutoAway': 1,
|
||||
'idleTimeLimit': 1,
|
||||
'roles': 1,
|
||||
'active': 1,
|
||||
'defaultRoom': 1,
|
||||
'customFields': 1,
|
||||
'requirePasswordChange': 1,
|
||||
'requirePasswordChangeReason': 1,
|
||||
'statusLivechat': 1,
|
||||
'banners': 1,
|
||||
'oauth.authorizedClients': 1,
|
||||
'_updatedAt': 1,
|
||||
'avatarETag': 1,
|
||||
'openBusinessHours': 1,
|
||||
'services.totp.enabled': 1,
|
||||
'services.email2fa.enabled': 1,
|
||||
},
|
||||
});
|
||||
if (!loggedUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
// using setImmediate here so login's method result is sent before we send the user data
|
||||
setImmediate(async () => server.added(client, 'users', userId, loggedUser));
|
||||
}
|
||||
server.on(DDP_EVENTS.LOGGED, async (info: Client) => {
|
||||
const { userId, connection } = info;
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('User not logged in');
|
||||
}
|
||||
|
||||
await Presence.newConnection(userId, connection.id, nodeID);
|
||||
|
||||
this.updateConnections();
|
||||
|
||||
// mimic Meteor's default publication that sends user data after login
|
||||
await sendUserData(info, userId);
|
||||
|
||||
server.emit('presence', { userId, connection });
|
||||
|
||||
void this.api?.broadcast('accounts.login', { userId, connection });
|
||||
});
|
||||
|
||||
server.on(DDP_EVENTS.LOGGEDOUT, (info) => {
|
||||
const { userId, connection } = info;
|
||||
|
||||
void this.api?.broadcast('accounts.logout', { userId, connection });
|
||||
|
||||
void this.updateConnections();
|
||||
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
void Presence.removeConnection(userId, connection.id, nodeID);
|
||||
});
|
||||
|
||||
server.on(DDP_EVENTS.DISCONNECTED, (info) => {
|
||||
const { userId, connection } = info;
|
||||
|
||||
void this.api?.broadcast('socket.disconnected', connection);
|
||||
|
||||
this.updateConnections();
|
||||
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
void Presence.removeConnection(userId, connection.id, nodeID);
|
||||
});
|
||||
|
||||
server.on(DDP_EVENTS.CONNECTED, ({ connection }) => {
|
||||
void this.api?.broadcast('socket.connected', connection);
|
||||
});
|
||||
}
|
||||
|
||||
override async started(): Promise<void> {
|
||||
// TODO this call creates a dependency to MeteorService, should it be a hard dependency? or can this call fail and be ignored?
|
||||
try {
|
||||
const versions = await MeteorService.getAutoUpdateClientVersions();
|
||||
|
||||
Object.keys(versions || {}).forEach((key) => {
|
||||
Autoupdate.updateVersion(versions[key]);
|
||||
});
|
||||
|
||||
this.app = polka()
|
||||
.use(proxy())
|
||||
.get('/health', async (_req, res) => {
|
||||
try {
|
||||
if (!this.api) {
|
||||
throw new Error('API not available');
|
||||
}
|
||||
|
||||
await this.api.nodeList();
|
||||
res.end('ok');
|
||||
} catch (err) {
|
||||
console.error('Service not healthy', err);
|
||||
|
||||
res.writeHead(500);
|
||||
res.end('not healthy');
|
||||
}
|
||||
})
|
||||
.get('*', function (_req, res) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
|
||||
res.writeHead(200);
|
||||
|
||||
res.end(
|
||||
`{"websocket":true,"origins":["*:*"],"cookie_needed":false,"entropy":${crypto.randomBytes(4).readUInt32LE(0)},"ms":true}`,
|
||||
);
|
||||
})
|
||||
.listen(PORT);
|
||||
|
||||
this.wss = new WebSocket.Server({ server: this.app.server });
|
||||
|
||||
this.wss.on('connection', (ws, req) => new Client(ws, req.url !== '/websocket', req));
|
||||
|
||||
void InstanceStatus.registerInstance('ddp-streamer', {});
|
||||
} catch (err) {
|
||||
console.error('DDPStreamer did not start correctly', err);
|
||||
}
|
||||
}
|
||||
|
||||
override async stopped(): Promise<void> {
|
||||
this.wss?.clients.forEach(function (client) {
|
||||
client.terminate();
|
||||
});
|
||||
|
||||
this.app?.server?.close();
|
||||
this.wss?.close();
|
||||
}
|
||||
}
|
||||
73
ee/apps/ddp-streamer/src/Publication.ts
Normal file
73
ee/apps/ddp-streamer/src/Publication.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
import type { Client } from './Client';
|
||||
import type { Server } from './Server';
|
||||
import type { IPacket } from './types/IPacket';
|
||||
import type { IPublication } from '../../../../apps/meteor/server/modules/streamer/types';
|
||||
|
||||
export class Publication extends EventEmitter implements IPublication {
|
||||
_session: IPublication['_session'];
|
||||
|
||||
connection: IPublication['connection'];
|
||||
|
||||
constructor(
|
||||
public client: Client,
|
||||
private packet: IPacket,
|
||||
private server: Server,
|
||||
) {
|
||||
super();
|
||||
this.packet = packet;
|
||||
client.subscriptions.set(packet.id, this);
|
||||
client.once('close', () => this.emit('stop', this.client, this.packet));
|
||||
this.once('stop', () => client.subscriptions.delete(packet.id));
|
||||
|
||||
this._session = {
|
||||
sendAdded: this.added.bind(this),
|
||||
socket: client,
|
||||
userId: client.userId,
|
||||
};
|
||||
|
||||
this.connection = client.connection;
|
||||
}
|
||||
|
||||
_isDeactivated(): this is IPublication & { _session: null } {
|
||||
return this._session === null;
|
||||
}
|
||||
|
||||
error(_error: Error): void {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
unblock(): void {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
ready(): void {
|
||||
return this.server.ready(this.client, this.packet);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.server.nosub(this.client, this.packet);
|
||||
this.emit('stop', this.client, this.packet);
|
||||
}
|
||||
|
||||
onStop(fn: (...args: any[]) => void): void {
|
||||
this.once('stop', fn);
|
||||
}
|
||||
|
||||
added(collection: string, id: string, fields: any): void {
|
||||
this.server.added(this.client, collection, id, fields);
|
||||
}
|
||||
|
||||
changed(collection: string, id: string, fields: any): void {
|
||||
this.server.changed(this.client, collection, id, fields);
|
||||
}
|
||||
|
||||
removed(collection: string, id: string): void {
|
||||
this.server.removed(this.client, collection, id);
|
||||
}
|
||||
|
||||
get userId() {
|
||||
return this.client.userId ?? null;
|
||||
}
|
||||
}
|
||||
108
ee/apps/ddp-streamer/src/Server.spec.ts
Normal file
108
ee/apps/ddp-streamer/src/Server.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { MeteorService } from '@rocket.chat/core-services';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
import { Server } from './Server';
|
||||
import type { IPacket } from './types/IPacket';
|
||||
|
||||
jest.mock('@rocket.chat/core-services', () => ({
|
||||
...jest.requireActual('@rocket.chat/core-services'),
|
||||
MeteorService: {
|
||||
callMethodWithToken: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@rocket.chat/logger', () => ({
|
||||
Logger: jest.fn().mockReturnValue({
|
||||
error: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockCallMethodWithToken = jest.mocked(MeteorService.callMethodWithToken);
|
||||
|
||||
function makeClient(readyState: number = WebSocket.OPEN) {
|
||||
return {
|
||||
ws: { readyState },
|
||||
userId: 'user1',
|
||||
userToken: 'token1',
|
||||
send: jest.fn(),
|
||||
} as unknown as Parameters<Server['call']>[0];
|
||||
}
|
||||
|
||||
function makePacket(method: string, id = 'test-id'): IPacket {
|
||||
return { msg: 'method', method, id, params: [] } as unknown as IPacket;
|
||||
}
|
||||
|
||||
describe('Server.call', () => {
|
||||
let server: Server;
|
||||
|
||||
beforeEach(() => {
|
||||
server = new Server();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('when the method is delegated to MeteorService', () => {
|
||||
it('returns the result value from MeteorService', async () => {
|
||||
mockCallMethodWithToken.mockResolvedValue({ result: 'some-value' } as any);
|
||||
const client = makeClient();
|
||||
const resultSpy = jest.spyOn(server, 'result');
|
||||
|
||||
await server.call(client, makePacket('someMethod'));
|
||||
|
||||
expect(resultSpy).toHaveBeenCalledWith(client, expect.objectContaining({ id: 'test-id' }), 'some-value');
|
||||
});
|
||||
|
||||
it('does not return an error when the method returns void', async () => {
|
||||
mockCallMethodWithToken.mockResolvedValue({ result: undefined } as any);
|
||||
const client = makeClient();
|
||||
const resultSpy = jest.spyOn(server, 'result');
|
||||
|
||||
await server.call(client, makePacket('setAvatarFromService'));
|
||||
|
||||
expect(resultSpy).toHaveBeenCalledWith(client, expect.objectContaining({ id: 'test-id' }), undefined);
|
||||
});
|
||||
|
||||
it('calls result with an error when MeteorService throws', async () => {
|
||||
mockCallMethodWithToken.mockRejectedValue(new Error('boom'));
|
||||
const client = makeClient();
|
||||
const resultSpy = jest.spyOn(server, 'result');
|
||||
|
||||
await server.call(client, makePacket('someMethod'));
|
||||
|
||||
expect(resultSpy).toHaveBeenCalledWith(client, expect.objectContaining({ id: 'test-id' }), null, expect.any(Error));
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the method is registered locally', () => {
|
||||
it('returns the result value from the local method', async () => {
|
||||
server.methods({ localMethod: async () => 'local-result' });
|
||||
const client = makeClient();
|
||||
const resultSpy = jest.spyOn(server, 'result');
|
||||
|
||||
await server.call(client, makePacket('localMethod'));
|
||||
|
||||
expect(resultSpy).toHaveBeenCalledWith(client, expect.objectContaining({ id: 'test-id' }), 'local-result');
|
||||
});
|
||||
|
||||
it('does not return an error when the local method returns void', async () => {
|
||||
server.methods({ voidMethod: async () => undefined });
|
||||
const client = makeClient();
|
||||
const resultSpy = jest.spyOn(server, 'result');
|
||||
|
||||
await server.call(client, makePacket('voidMethod'));
|
||||
|
||||
expect(resultSpy).toHaveBeenCalledWith(client, expect.objectContaining({ id: 'test-id' }), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the client WebSocket is not open', () => {
|
||||
it('does nothing', async () => {
|
||||
const client = makeClient(WebSocket.CLOSED);
|
||||
const resultSpy = jest.spyOn(server, 'result');
|
||||
|
||||
await server.call(client, makePacket('anyMethod'));
|
||||
|
||||
expect(resultSpy).not.toHaveBeenCalled();
|
||||
expect(mockCallMethodWithToken).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
199
ee/apps/ddp-streamer/src/Server.ts
Normal file
199
ee/apps/ddp-streamer/src/Server.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
import type { IServiceMetrics } from '@rocket.chat/core-services';
|
||||
import { MeteorService, isMeteorError, MeteorError } from '@rocket.chat/core-services';
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
import ejson from 'ejson';
|
||||
import { v1 as uuidv1 } from 'uuid';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
import type { Client } from './Client';
|
||||
import { Publication } from './Publication';
|
||||
import { DDP_EVENTS } from './constants';
|
||||
import type { IPacket } from './types/IPacket';
|
||||
|
||||
const logger = new Logger('DDP-Streamer');
|
||||
|
||||
type SubscriptionFn = (this: Publication, eventName: string, options: object) => void;
|
||||
type MethodFn = (this: Client, ...args: any[]) => any;
|
||||
type Methods = {
|
||||
[k: string]: MethodFn;
|
||||
};
|
||||
|
||||
const handleInternalException = (err: unknown, msg: string): MeteorError => {
|
||||
if (err instanceof MeteorError) {
|
||||
return err;
|
||||
}
|
||||
|
||||
// default errors are logged to the console and redacted from the client
|
||||
// TODO switch to using the logger (ideally broker.logger)
|
||||
logger.error({ msg, err });
|
||||
|
||||
return new MeteorError(500, 'Internal server error');
|
||||
};
|
||||
|
||||
export const SERVER_ID = ejson.stringify({ msg: 'server_id', server_id: '0' });
|
||||
|
||||
export class Server extends EventEmitter {
|
||||
private _subscriptions = new Map<string, SubscriptionFn>();
|
||||
|
||||
private _methods = new Map<string, MethodFn>();
|
||||
|
||||
private metrics?: IServiceMetrics;
|
||||
|
||||
public readonly id = uuidv1();
|
||||
|
||||
serialize = ejson.stringify;
|
||||
|
||||
parse = (data: WebSocket.Data, isBinary: boolean): IPacket => {
|
||||
if (isBinary) {
|
||||
throw new MeteorError(500, 'Binary data not supported');
|
||||
}
|
||||
const packet = data.toString();
|
||||
|
||||
const payload = packet.startsWith('[') ? JSON.parse(packet)[0] : packet;
|
||||
return ejson.parse(payload);
|
||||
};
|
||||
|
||||
setMetrics(metrics: IServiceMetrics): void {
|
||||
this.metrics = metrics;
|
||||
}
|
||||
|
||||
async call(client: Client, packet: IPacket): Promise<void> {
|
||||
// if client is not connected we don't need to do anything
|
||||
if (client.ws.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// if method was not defined on DDP Streamer we fall back to Meteor
|
||||
if (!this._methods.has(packet.method)) {
|
||||
const result = await MeteorService.callMethodWithToken(client.userId, client.userToken, packet.method, packet.params);
|
||||
return this.result(client, packet, result.result);
|
||||
}
|
||||
|
||||
const fn = this._methods.get(packet.method);
|
||||
if (!fn) {
|
||||
throw new MeteorError(404, `Method '${packet.method}' not found`);
|
||||
}
|
||||
|
||||
const result = await fn.apply(client, packet.params);
|
||||
return this.result(client, packet, result);
|
||||
} catch (err: unknown) {
|
||||
return this.result(client, packet, null, handleInternalException(err, 'Method call error'));
|
||||
}
|
||||
}
|
||||
|
||||
methods(obj: Methods): void {
|
||||
Object.entries(obj).forEach(([name, fn]) => {
|
||||
if (this._methods.has(name)) {
|
||||
return;
|
||||
}
|
||||
this._methods.set(name, fn);
|
||||
});
|
||||
}
|
||||
|
||||
async subscribe(client: Client, packet: IPacket): Promise<void> {
|
||||
// if client is not connected we don't need to do anything
|
||||
if (client.ws.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!this._subscriptions.has(packet.name)) {
|
||||
throw new MeteorError(404, `Subscription '${packet.name}' not found`);
|
||||
}
|
||||
const fn = this._subscriptions.get(packet.name);
|
||||
if (!fn) {
|
||||
throw new MeteorError(404, `Subscription '${packet.name}' not found`);
|
||||
}
|
||||
|
||||
const end = this.metrics?.timer('rocketchat_subscription', { subscription: packet.name });
|
||||
|
||||
const publication = new Publication(client, packet, this);
|
||||
const [eventName, options] = packet.params;
|
||||
await fn.call(publication, eventName, options);
|
||||
|
||||
end?.();
|
||||
} catch (err: unknown) {
|
||||
return this.nosub(client, packet, handleInternalException(err, 'Subscription error'));
|
||||
}
|
||||
}
|
||||
|
||||
publish(name: string, fn: SubscriptionFn): void {
|
||||
if (this._subscriptions.has(name)) {
|
||||
return;
|
||||
}
|
||||
this._subscriptions.set(name, fn);
|
||||
}
|
||||
|
||||
stream(stream: string, fn: SubscriptionFn): void {
|
||||
return this.publish(`stream-${stream}`, fn);
|
||||
}
|
||||
|
||||
result(client: Client, { id }: IPacket, result?: any, error?: Error | MeteorError): void {
|
||||
client.send(
|
||||
this.serialize({
|
||||
[DDP_EVENTS.MSG]: DDP_EVENTS.RESULT,
|
||||
id,
|
||||
...(result && { result }),
|
||||
...(error && { error: isMeteorError(error) ? error.toJSON() : error }),
|
||||
}),
|
||||
);
|
||||
return client.send(
|
||||
this.serialize({
|
||||
[DDP_EVENTS.MSG]: DDP_EVENTS.UPDATED,
|
||||
[DDP_EVENTS.METHODS]: [id],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
nosub(client: Client, { id }: IPacket, error?: Error | MeteorError): void {
|
||||
return client.send(
|
||||
this.serialize({
|
||||
[DDP_EVENTS.MSG]: DDP_EVENTS.NO_SUBSCRIBE,
|
||||
id,
|
||||
...(error && { error: isMeteorError(error) ? error.toJSON() : error }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
ready(client: Client, packet: IPacket): void {
|
||||
return client.send(
|
||||
this.serialize({
|
||||
[DDP_EVENTS.MSG]: DDP_EVENTS.READY,
|
||||
[DDP_EVENTS.SUBSCRIPTIONS]: [packet.id],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
added(client: Client, collection: string, id: string, fields: any): void {
|
||||
return client.send(
|
||||
this.serialize({
|
||||
[DDP_EVENTS.MSG]: DDP_EVENTS.ADDED,
|
||||
[DDP_EVENTS.COLLECTION]: collection,
|
||||
[DDP_EVENTS.ID]: id,
|
||||
[DDP_EVENTS.FIELDS]: fields,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
changed(client: Client, collection: string, id: string, fields: any): void {
|
||||
return client.send(
|
||||
this.serialize({
|
||||
[DDP_EVENTS.MSG]: DDP_EVENTS.CHANGED,
|
||||
[DDP_EVENTS.COLLECTION]: collection,
|
||||
[DDP_EVENTS.ID]: id,
|
||||
[DDP_EVENTS.FIELDS]: fields,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
removed(client: Client, collection: string, id: string): void {
|
||||
return client.send(
|
||||
this.serialize({
|
||||
[DDP_EVENTS.MSG]: DDP_EVENTS.REMOVED,
|
||||
[DDP_EVENTS.COLLECTION]: collection,
|
||||
[DDP_EVENTS.ID]: id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
102
ee/apps/ddp-streamer/src/Streamer.ts
Normal file
102
ee/apps/ddp-streamer/src/Streamer.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { api } from '@rocket.chat/core-services';
|
||||
import type { StreamNames } from '@rocket.chat/ddp-client';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
import { server } from './configureServer';
|
||||
import { DDP_EVENTS } from './constants';
|
||||
import { isEmpty } from './lib/utils';
|
||||
import { Streamer, StreamerCentral } from '../../../../apps/meteor/server/modules/streamer/streamer.module';
|
||||
import type { DDPSubscription, Connection, TransformMessage } from '../../../../apps/meteor/server/modules/streamer/types';
|
||||
|
||||
StreamerCentral.on('broadcast', (name, eventName, args) => {
|
||||
void api.broadcast('stream', [name, eventName, args]);
|
||||
});
|
||||
|
||||
export class Stream<N extends StreamNames> extends Streamer<N> {
|
||||
registerPublication(name: string, fn: (eventName: string, options: boolean | { useCollection?: boolean; args?: any }) => void): void {
|
||||
server.publish(name, fn);
|
||||
}
|
||||
|
||||
registerMethod(methods: Record<string, (eventName: string, ...args: any[]) => any>): void {
|
||||
server.methods(methods);
|
||||
}
|
||||
|
||||
changedPayload(collection: string, id: string, fields: Record<string, any>): string | false {
|
||||
return (
|
||||
!isEmpty(fields) &&
|
||||
server.serialize({
|
||||
[DDP_EVENTS.MSG]: DDP_EVENTS.CHANGED,
|
||||
[DDP_EVENTS.COLLECTION]: collection,
|
||||
[DDP_EVENTS.ID]: id,
|
||||
[DDP_EVENTS.FIELDS]: fields,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override async sendToManySubscriptions(
|
||||
subscriptions: Set<DDPSubscription>,
|
||||
origin: Connection | undefined,
|
||||
eventName: string,
|
||||
args: any[],
|
||||
getMsg: string | TransformMessage,
|
||||
): Promise<void> {
|
||||
if (typeof getMsg === 'function') {
|
||||
return super.sendToManySubscriptions(subscriptions, origin, eventName, args, getMsg);
|
||||
}
|
||||
|
||||
const options = {
|
||||
fin: true, // sending a single fragment message
|
||||
rsv1: false, // don"t set rsv1 bit (no compression)
|
||||
opcode: 1, // opcode for a text frame
|
||||
mask: false, // set false for client-side
|
||||
readOnly: false, // the data can be modified as needed
|
||||
};
|
||||
|
||||
const data = {
|
||||
meteor: [Buffer.concat(WebSocket.Sender.frame(Buffer.from(`a${JSON.stringify([getMsg])}`), options))],
|
||||
normal: [Buffer.concat(WebSocket.Sender.frame(Buffer.from(getMsg), options))],
|
||||
};
|
||||
|
||||
for (const { subscription } of subscriptions) {
|
||||
// if the connection state is not open anymore, it somehow got to a weird state,
|
||||
// we'll emit close so it can clean up the weird state, and so we stop emitting to it
|
||||
if (subscription.client.ws.readyState !== WebSocket.OPEN) {
|
||||
subscription.stop();
|
||||
subscription.client.ws.close();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.retransmitToSelf === false && origin && origin === subscription.connection) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!(await this.isEmitAllowed(subscription, eventName, ...args))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const frame = data[subscription.client.meteorClient ? 'meteor' : 'normal'];
|
||||
|
||||
subscription.client.ws._sender.sendFrame(frame, (err: unknown) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ERR_STREAM_DESTROYED') {
|
||||
console.warn('Trying to send data to destroyed stream, closing connection.');
|
||||
|
||||
// if we still tried to send data to a destroyed stream, we'll try again to close the connection
|
||||
if (subscription.client.ws.readyState !== WebSocket.OPEN) {
|
||||
subscription.stop();
|
||||
subscription.client.ws.close();
|
||||
}
|
||||
}
|
||||
console.error('Error trying to send data to stream.', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
140
ee/apps/ddp-streamer/src/configureServer.ts
Normal file
140
ee/apps/ddp-streamer/src/configureServer.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
import { Account, Presence, MeteorService, MeteorError } from '@rocket.chat/core-services';
|
||||
import { UserStatus } from '@rocket.chat/core-typings';
|
||||
|
||||
import { Server } from './Server';
|
||||
import { DDP_EVENTS, WS_ERRORS } from './constants';
|
||||
import { Autoupdate } from './lib/Autoupdate';
|
||||
|
||||
export const server = new Server();
|
||||
|
||||
export const events = new EventEmitter();
|
||||
|
||||
const loginServiceConfigurationCollection = 'meteor_accounts_loginServiceConfiguration';
|
||||
const loginServiceConfigurationPublication = 'meteor.loginServiceConfiguration';
|
||||
const loginServices = new Map<string, any>();
|
||||
|
||||
MeteorService.getLoginServiceConfiguration()
|
||||
.then((records = []) => records.forEach((record) => loginServices.set(record._id, record)))
|
||||
.catch((err) => console.error('DDPStreamer not able to retrieve login services configuration', err));
|
||||
|
||||
server.publish(loginServiceConfigurationPublication, async function () {
|
||||
loginServices.forEach((record) => this.added(loginServiceConfigurationCollection, record._id, record));
|
||||
|
||||
const fn = (action: string, record: any): void => {
|
||||
switch (action) {
|
||||
case 'added':
|
||||
case 'changed':
|
||||
loginServices.set(record._id, record);
|
||||
this[action](loginServiceConfigurationCollection, record._id, record);
|
||||
break;
|
||||
case 'removed':
|
||||
loginServices.delete(record._id);
|
||||
this[action](loginServiceConfigurationCollection, record._id);
|
||||
}
|
||||
};
|
||||
|
||||
events.on(loginServiceConfigurationPublication, fn);
|
||||
|
||||
this.onStop(() => {
|
||||
events.removeListener(loginServiceConfigurationPublication, fn);
|
||||
});
|
||||
|
||||
this.ready();
|
||||
});
|
||||
|
||||
const autoUpdateCollection = 'meteor_autoupdate_clientVersions';
|
||||
server.publish(autoUpdateCollection, function () {
|
||||
Autoupdate.getVersions().forEach((version, arch) => {
|
||||
this.added(autoUpdateCollection, arch, version);
|
||||
});
|
||||
|
||||
const fn = (record: any): void => {
|
||||
const { _id, ...version } = record;
|
||||
this.changed(autoUpdateCollection, _id, version);
|
||||
};
|
||||
|
||||
Autoupdate.on('update', fn);
|
||||
|
||||
this.onStop(() => {
|
||||
Autoupdate.removeListener('update', fn);
|
||||
});
|
||||
|
||||
this.ready();
|
||||
});
|
||||
|
||||
server.methods({
|
||||
async 'login'({ resume }: { resume: string }) {
|
||||
try {
|
||||
const result = await Account.login({ resume });
|
||||
if (!result) {
|
||||
throw new MeteorError(403, "You've been logged out by the server. Please log in again");
|
||||
}
|
||||
|
||||
this.userId = result.uid;
|
||||
this.userToken = result.hashedToken;
|
||||
this.connection.loginToken = result.hashedToken;
|
||||
|
||||
this.emit(DDP_EVENTS.LOGGED);
|
||||
|
||||
server.emit(DDP_EVENTS.LOGGED, this);
|
||||
|
||||
return {
|
||||
id: result.uid,
|
||||
token: result.token,
|
||||
tokenExpires: result.tokenExpires,
|
||||
type: result.type,
|
||||
};
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async 'logout'() {
|
||||
if (this.userToken && this.userId) {
|
||||
await Account.logout({ userId: this.userId, token: this.userToken });
|
||||
}
|
||||
|
||||
this.emit(DDP_EVENTS.LOGGEDOUT);
|
||||
server.emit(DDP_EVENTS.LOGGEDOUT, this);
|
||||
|
||||
this.userToken = undefined;
|
||||
this.userId = undefined;
|
||||
|
||||
// Close connection after return success to the method call.
|
||||
// This ensures all the subscriptions will be closed, meteor makes it manually
|
||||
// here https://github.com/meteor/meteor/blob/2377ebe879d9b965d699f599392d4e8047eb7d78/packages/ddp-server/livedata_server.js#L781
|
||||
// re doing the default subscriptions.
|
||||
setTimeout(() => {
|
||||
this.ws.close(WS_ERRORS.CLOSE_PROTOCOL_ERROR);
|
||||
}, 1);
|
||||
},
|
||||
'UserPresence:setDefaultStatus'(status) {
|
||||
const { userId } = this;
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
return Presence.setStatus(userId, status);
|
||||
},
|
||||
'UserPresence:online'() {
|
||||
const { userId, session } = this;
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
return Presence.setConnectionStatus(userId, UserStatus.ONLINE, session);
|
||||
},
|
||||
'UserPresence:away'() {
|
||||
const { userId, session } = this;
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
return Presence.setConnectionStatus(userId, UserStatus.AWAY, session);
|
||||
},
|
||||
'setUserStatus'(status, statusText) {
|
||||
const { userId } = this;
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
return Presence.setStatus(userId, status, statusText);
|
||||
},
|
||||
});
|
||||
48
ee/apps/ddp-streamer/src/constants.ts
Normal file
48
ee/apps/ddp-streamer/src/constants.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export const STREAMER_EVENTS = {
|
||||
STREAM: 'stream',
|
||||
USER_CHANGED: 'user-changed',
|
||||
};
|
||||
|
||||
export const DDP_EVENTS = {
|
||||
ID: 'id',
|
||||
FIELDS: 'fields',
|
||||
COLLECTION: 'collection',
|
||||
CLEARED: 'cleared',
|
||||
METHODS: 'methods',
|
||||
|
||||
MSG: 'msg',
|
||||
READY: 'ready',
|
||||
ADDED: 'added',
|
||||
CHANGED: 'changed',
|
||||
REMOVED: 'removed',
|
||||
RESUME: 'resume',
|
||||
RESULT: 'result',
|
||||
METHOD: 'method',
|
||||
UPDATED: 'updated',
|
||||
PING: 'ping',
|
||||
PONG: 'pong',
|
||||
SUBSCRIBE: 'sub',
|
||||
CONNECT: 'connect',
|
||||
CONNECTED: 'connected',
|
||||
SUBSCRIPTIONS: 'subs',
|
||||
NO_SUBSCRIBE: 'nosub',
|
||||
UNSUBSCRIBE: 'unsub',
|
||||
DISCONNECTED: 'disconnected',
|
||||
LOGGED: 'logged',
|
||||
LOGGEDOUT: 'loggedout',
|
||||
};
|
||||
|
||||
export const WS_ERRORS = {
|
||||
CLOSE_PROTOCOL_ERROR: 1002,
|
||||
UNSUPPORTED_DATA: 1007,
|
||||
|
||||
TIMEOUT: 4000,
|
||||
};
|
||||
|
||||
export const WS_ERRORS_MESSAGES = {
|
||||
CLOSE_PROTOCOL_ERROR: 'CLOSE_PROTOCOL_ERROR',
|
||||
UNSUPPORTED_DATA: 'UNSUPPORTED_DATA',
|
||||
TIMEOUT: 'TIMEOUT',
|
||||
};
|
||||
|
||||
export const TIMEOUT = 1000 * 30; // 30 seconds
|
||||
9
ee/apps/ddp-streamer/src/fips.ts
Normal file
9
ee/apps/ddp-streamer/src/fips.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
crypto.setFips(true);
|
||||
|
||||
if (!crypto.getFips()) {
|
||||
throw new Error('FIPS mode was not enabled after crypto.setFips(true)');
|
||||
}
|
||||
|
||||
console.log('FIPS COMPLIANCE CHECK: YES');
|
||||
20
ee/apps/ddp-streamer/src/lib/Autoupdate.ts
Normal file
20
ee/apps/ddp-streamer/src/lib/Autoupdate.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
import type { AutoUpdateRecord } from '@rocket.chat/core-services';
|
||||
|
||||
class AutoupdateSingleton extends EventEmitter {
|
||||
private versions = new Map<string, Omit<AutoUpdateRecord, '_id'>>();
|
||||
|
||||
public updateVersion(record: AutoUpdateRecord): void {
|
||||
const { _id, ...version } = record;
|
||||
this.versions.set(_id, version);
|
||||
|
||||
this.emit('update', record);
|
||||
}
|
||||
|
||||
public getVersions(): Map<string, Omit<AutoUpdateRecord, '_id'>> {
|
||||
return this.versions;
|
||||
}
|
||||
}
|
||||
|
||||
export const Autoupdate = new AutoupdateSingleton();
|
||||
9
ee/apps/ddp-streamer/src/lib/utils.ts
Normal file
9
ee/apps/ddp-streamer/src/lib/utils.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const isEmpty = function (obj: object | any[] | string): boolean {
|
||||
if (obj == null) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(obj) || typeof obj === 'string') {
|
||||
return obj.length === 0;
|
||||
}
|
||||
return !Object.values(obj).some((value) => value !== null);
|
||||
};
|
||||
41
ee/apps/ddp-streamer/src/proxy.ts
Normal file
41
ee/apps/ddp-streamer/src/proxy.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { IncomingMessage, RequestOptions, ServerResponse } from 'http';
|
||||
import http from 'http';
|
||||
import url from 'url';
|
||||
|
||||
import type polka from 'polka';
|
||||
|
||||
const isProdEnv = process.env.NODE_ENV === 'production';
|
||||
|
||||
const skipProxyPaths = [/^\/sockjs\/info\?cb=/, /^\/health/];
|
||||
|
||||
export function proxy(): (req: IncomingMessage, res: ServerResponse, next: polka.Next) => void {
|
||||
if (isProdEnv) {
|
||||
return (_req, _res, next) => next();
|
||||
}
|
||||
|
||||
return (req, res, next) => {
|
||||
if (skipProxyPaths.some((regex) => regex.test(req.url || ''))) {
|
||||
return next();
|
||||
}
|
||||
|
||||
req.pause();
|
||||
|
||||
const options: RequestOptions = url.parse(req.url || '');
|
||||
options.headers = req.headers;
|
||||
options.method = req.method;
|
||||
options.agent = false;
|
||||
options.hostname = 'localhost';
|
||||
options.port = 3000;
|
||||
|
||||
const connector = http.request(options, function (serverResponse) {
|
||||
serverResponse.pause();
|
||||
if (serverResponse.statusCode) {
|
||||
res.writeHead(serverResponse.statusCode, serverResponse.headers);
|
||||
}
|
||||
serverResponse.pipe(res);
|
||||
serverResponse.resume();
|
||||
});
|
||||
req.pipe(connector);
|
||||
req.resume();
|
||||
};
|
||||
}
|
||||
70
ee/apps/ddp-streamer/src/service.ts
Executable file
70
ee/apps/ddp-streamer/src/service.ts
Executable file
@@ -0,0 +1,70 @@
|
||||
import os from 'os';
|
||||
|
||||
import { api, getConnection, getTrashCollection } from '@rocket.chat/core-services';
|
||||
import { InstanceStatus } from '@rocket.chat/instance-status';
|
||||
import { registerServiceModels } from '@rocket.chat/models';
|
||||
import { startBroker } from '@rocket.chat/network-broker';
|
||||
import { startTracing } from '@rocket.chat/tracing';
|
||||
|
||||
void (async () => {
|
||||
const { db, client } = await getConnection();
|
||||
|
||||
startTracing({ service: 'ddp-streamer', db: client });
|
||||
|
||||
registerServiceModels(db, await getTrashCollection());
|
||||
|
||||
api.setBroker(
|
||||
startBroker({
|
||||
nodeID: `${os.hostname().toLowerCase()}-${InstanceStatus.id()}`,
|
||||
}),
|
||||
);
|
||||
|
||||
// need to import service after models are registered
|
||||
const { NotificationsModule } = await import('../../../../apps/meteor/server/modules/notifications/notifications.module');
|
||||
const { DDPStreamer } = await import('./DDPStreamer');
|
||||
const { Stream } = await import('./Streamer');
|
||||
|
||||
const notifications = new NotificationsModule(Stream);
|
||||
|
||||
notifications.configure();
|
||||
|
||||
api.registerService(new DDPStreamer(notifications));
|
||||
|
||||
await api.start();
|
||||
})();
|
||||
|
||||
/**
|
||||
* If some promise is rejected and doesn't have a catch (unhandledRejection) it may cause the process to exit.
|
||||
*
|
||||
* Since unhandled rejections are deprecated in NodeJS:
|
||||
* (node:83382) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections
|
||||
* that are not handled will terminate the Node.js process with a non-zero exit code.
|
||||
* we will start respecting this and exit the process to prevent these kind of problems.
|
||||
*/
|
||||
|
||||
process.on('unhandledRejection', (error) => {
|
||||
console.error('=== UnHandledPromiseRejection ===');
|
||||
console.error(error);
|
||||
console.error('---------------------------------');
|
||||
console.error(
|
||||
'Setting EXIT_UNHANDLEDPROMISEREJECTION will cause the process to exit allowing your service to automatically restart the process',
|
||||
);
|
||||
console.error('Future node.js versions will automatically exit the process');
|
||||
console.error('=================================');
|
||||
|
||||
if (process.env.TEST_MODE || process.env.NODE_ENV === 'development' || process.env.EXIT_UNHANDLEDPROMISEREJECTION) {
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
process.on('uncaughtException', async (error) => {
|
||||
console.error('=== UnCaughtException ===');
|
||||
console.error(error);
|
||||
console.error('-------------------------');
|
||||
console.error('Errors like this can cause oplog processing errors.');
|
||||
console.error('===========================');
|
||||
|
||||
if (process.env.TEST_MODE || process.env.NODE_ENV === 'development' || process.env.EXIT_UNHANDLEDPROMISEREJECTION) {
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
9
ee/apps/ddp-streamer/src/types/IPacket.ts
Normal file
9
ee/apps/ddp-streamer/src/types/IPacket.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface IPacket {
|
||||
name: string;
|
||||
id: string;
|
||||
method: string;
|
||||
msg: string;
|
||||
version: string;
|
||||
support: string[];
|
||||
params: any[];
|
||||
}
|
||||
20
ee/apps/ddp-streamer/tsconfig.json
Normal file
20
ee/apps/ddp-streamer/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "@rocket.chat/tsconfig/server.json",
|
||||
"compilerOptions": {
|
||||
"strictPropertyInitialization": false, // TODO: Remove this line
|
||||
"outDir": "./dist",
|
||||
"types": ["jest", "node"],
|
||||
|
||||
/* This service typechecks sources from apps/meteor, which has
|
||||
`installConfig.hoistingLimits: "workspaces"` and therefore its own nested
|
||||
copy of mongodb. Without pinning, those sources resolve `mongodb` to
|
||||
apps/meteor/node_modules while @rocket.chat/models resolves it to the root
|
||||
copy, and the two identical-but-distinct instances fail to unify. */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"mongodb": ["../../../node_modules/mongodb"]
|
||||
}
|
||||
},
|
||||
"include": ["./src/**/*", "./definition"],
|
||||
"exclude": ["./dist", "./ecosystem.config.js"]
|
||||
}
|
||||
4248
ee/apps/omnichannel-transcript/CHANGELOG.md
Normal file
4248
ee/apps/omnichannel-transcript/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
65
ee/apps/omnichannel-transcript/package.json
Normal file
65
ee/apps/omnichannel-transcript/package.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "@rocket.chat/omnichannel-transcript",
|
||||
"version": "0.5.1",
|
||||
"private": true,
|
||||
"description": "Rocket.Chat service",
|
||||
"keywords": [
|
||||
"rocketchat"
|
||||
],
|
||||
"author": "Rocket.Chat",
|
||||
"main": "./dist/ee/apps/omnichannel-transcript/src/service.js",
|
||||
"files": [
|
||||
"/dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"lint": "eslint .",
|
||||
"ms": "TRANSPORTER=${TRANSPORTER:-TCP} MONGO_URL=${MONGO_URL:-mongodb://localhost:3001/meteor} ts-node --files src/service.ts",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "~4.5.1",
|
||||
"@rocket.chat/core-services": "workspace:^",
|
||||
"@rocket.chat/core-typings": "workspace:^",
|
||||
"@rocket.chat/emitter": "workspace:~",
|
||||
"@rocket.chat/i18n": "workspace:^",
|
||||
"@rocket.chat/logger": "workspace:^",
|
||||
"@rocket.chat/model-typings": "workspace:^",
|
||||
"@rocket.chat/models": "workspace:^",
|
||||
"@rocket.chat/network-broker": "workspace:^",
|
||||
"@rocket.chat/omnichannel-services": "workspace:^",
|
||||
"@rocket.chat/pdf-worker": "workspace:^",
|
||||
"@rocket.chat/tools": "workspace:^",
|
||||
"@rocket.chat/tracing": "workspace:^",
|
||||
"@types/node": "~24.13.2",
|
||||
"ejson": "^2.2.3",
|
||||
"eventemitter3": "^5.0.4",
|
||||
"i18next": "~23.4.9",
|
||||
"i18next-sprintf-postprocessor": "^0.2.2",
|
||||
"mem": "^8.1.1",
|
||||
"moleculer": "^0.14.35",
|
||||
"moment-timezone": "^0.5.48",
|
||||
"mongo-message-queue": "^1.1.0",
|
||||
"mongodb": "6.16.0",
|
||||
"nats": "^2.28.2",
|
||||
"pino": "10.3.1",
|
||||
"polka": "^0.5.2",
|
||||
"prom-client": "^14.2.0",
|
||||
"prometheus-gc-stats": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rocket.chat/tsconfig": "workspace:*",
|
||||
"@types/i18next-sprintf-postprocessor": "^0.2.3",
|
||||
"@types/polka": "^0.5.8",
|
||||
"@types/prometheus-gc-stats": "^0.6.4",
|
||||
"@types/react": "~19.2.18",
|
||||
"eslint": "~9.39.5",
|
||||
"react": "~19.2.8",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"volta": {
|
||||
"extends": "../../../package.json"
|
||||
}
|
||||
}
|
||||
9
ee/apps/omnichannel-transcript/src/fips.ts
Normal file
9
ee/apps/omnichannel-transcript/src/fips.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
crypto.setFips(true);
|
||||
|
||||
if (!crypto.getFips()) {
|
||||
throw new Error('FIPS mode was not enabled after crypto.setFips(true)');
|
||||
}
|
||||
|
||||
console.log('FIPS COMPLIANCE CHECK: YES');
|
||||
27
ee/apps/omnichannel-transcript/src/i18n.ts
Normal file
27
ee/apps/omnichannel-transcript/src/i18n.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { availableTranslationNamespaces, defaultTranslationNamespace, extractTranslationNamespaces } from '@rocket.chat/i18n';
|
||||
import languages from '@rocket.chat/i18n/dist/languages';
|
||||
import i18next from 'i18next';
|
||||
import sprintf from 'i18next-sprintf-postprocessor';
|
||||
|
||||
const i18n = i18next.use(sprintf);
|
||||
|
||||
void i18n.init({
|
||||
lng: 'en',
|
||||
fallbackLng: 'en',
|
||||
defaultNS: defaultTranslationNamespace,
|
||||
ns: availableTranslationNamespaces,
|
||||
nsSeparator: '.',
|
||||
resources: Object.fromEntries(
|
||||
languages.map((language) => [
|
||||
language,
|
||||
extractTranslationNamespaces(
|
||||
// TODO: commonjs is terrible but we don't have esm build yet
|
||||
// eslint-disable-next-line import-x/no-dynamic-require
|
||||
require(`@rocket.chat/i18n/dist/resources/${language}.i18n.json`) as unknown as Record<string, string>,
|
||||
),
|
||||
]),
|
||||
),
|
||||
initImmediate: false,
|
||||
});
|
||||
|
||||
export { i18n };
|
||||
41
ee/apps/omnichannel-transcript/src/service.ts
Normal file
41
ee/apps/omnichannel-transcript/src/service.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { api, getConnection, getTrashCollection } from '@rocket.chat/core-services';
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
import { registerServiceModels } from '@rocket.chat/models';
|
||||
import { startBroker } from '@rocket.chat/network-broker';
|
||||
import { startTracing } from '@rocket.chat/tracing';
|
||||
import polka from 'polka';
|
||||
|
||||
import { i18n } from './i18n';
|
||||
|
||||
const PORT = process.env.PORT || 3036;
|
||||
|
||||
void (async () => {
|
||||
const { db, client } = await getConnection();
|
||||
|
||||
startTracing({ service: 'omnichannel-transcript', db: client });
|
||||
|
||||
registerServiceModels(db, await getTrashCollection());
|
||||
|
||||
api.setBroker(startBroker());
|
||||
|
||||
// need to import service after models are registered
|
||||
const { OmnichannelTranscript } = await import('@rocket.chat/omnichannel-services');
|
||||
|
||||
api.registerService(new OmnichannelTranscript(Logger, i18n), ['queue-worker']);
|
||||
|
||||
await api.start();
|
||||
|
||||
polka()
|
||||
.get('/health', async function (_req, res) {
|
||||
try {
|
||||
await api.nodeList();
|
||||
res.end('ok');
|
||||
} catch (err) {
|
||||
console.error('Service not healthy', err);
|
||||
|
||||
res.writeHead(500);
|
||||
res.end('not healthy');
|
||||
}
|
||||
})
|
||||
.listen(PORT);
|
||||
})();
|
||||
10
ee/apps/omnichannel-transcript/tsconfig.json
Normal file
10
ee/apps/omnichannel-transcript/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@rocket.chat/tsconfig/server.json",
|
||||
"compilerOptions": {
|
||||
"strictPropertyInitialization": false,
|
||||
"outDir": "./dist/ee/apps/omnichannel-transcript/src",
|
||||
},
|
||||
"files": ["./src/service.ts", "./src/fips.ts"],
|
||||
"include": ["../../../apps/meteor/definition/externals/meteor"],
|
||||
"exclude": ["./dist"]
|
||||
}
|
||||
3945
ee/apps/presence-service/CHANGELOG.md
Normal file
3945
ee/apps/presence-service/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
55
ee/apps/presence-service/package.json
Normal file
55
ee/apps/presence-service/package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "@rocket.chat/presence-service",
|
||||
"version": "0.6.1",
|
||||
"private": true,
|
||||
"description": "Rocket.Chat Presence service",
|
||||
"keywords": [
|
||||
"rocketchat"
|
||||
],
|
||||
"author": "Rocket.Chat",
|
||||
"main": "./dist/ee/apps/presence-service/src/service.js",
|
||||
"files": [
|
||||
"/dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"lint": "eslint .",
|
||||
"ms": "TRANSPORTER=${TRANSPORTER:-TCP} MONGO_URL=${MONGO_URL:-mongodb://localhost:3001/meteor} ts-node --files src/service.ts",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rocket.chat/core-services": "workspace:^",
|
||||
"@rocket.chat/core-typings": "workspace:^",
|
||||
"@rocket.chat/cron": "workspace:^",
|
||||
"@rocket.chat/emitter": "workspace:~",
|
||||
"@rocket.chat/logger": "workspace:^",
|
||||
"@rocket.chat/model-typings": "workspace:^",
|
||||
"@rocket.chat/models": "workspace:^",
|
||||
"@rocket.chat/network-broker": "workspace:^",
|
||||
"@rocket.chat/presence": "workspace:^",
|
||||
"@rocket.chat/tracing": "workspace:^",
|
||||
"@types/node": "~24.13.2",
|
||||
"ejson": "^2.2.3",
|
||||
"eventemitter3": "^5.0.4",
|
||||
"mem": "^8.1.1",
|
||||
"moleculer": "^0.14.35",
|
||||
"mongodb": "6.16.0",
|
||||
"nats": "^2.28.2",
|
||||
"pino": "10.3.1",
|
||||
"polka": "^0.5.2",
|
||||
"prom-client": "^14.2.0",
|
||||
"prometheus-gc-stats": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rocket.chat/tsconfig": "workspace:*",
|
||||
"@types/polka": "^0.5.8",
|
||||
"@types/prometheus-gc-stats": "^0.6.4",
|
||||
"eslint": "~9.39.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"volta": {
|
||||
"extends": "../../../package.json"
|
||||
}
|
||||
}
|
||||
9
ee/apps/presence-service/src/fips.ts
Normal file
9
ee/apps/presence-service/src/fips.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
crypto.setFips(true);
|
||||
|
||||
if (!crypto.getFips()) {
|
||||
throw new Error('FIPS mode was not enabled after crypto.setFips(true)');
|
||||
}
|
||||
|
||||
console.log('FIPS COMPLIANCE CHECK: YES');
|
||||
41
ee/apps/presence-service/src/service.ts
Executable file
41
ee/apps/presence-service/src/service.ts
Executable file
@@ -0,0 +1,41 @@
|
||||
import { api, getConnection, getTrashCollection } from '@rocket.chat/core-services';
|
||||
import { cronJobs } from '@rocket.chat/cron';
|
||||
import { registerServiceModels } from '@rocket.chat/models';
|
||||
import { startBroker } from '@rocket.chat/network-broker';
|
||||
import { startTracing } from '@rocket.chat/tracing';
|
||||
import polka from 'polka';
|
||||
|
||||
const PORT = process.env.PORT || 3031;
|
||||
|
||||
void (async () => {
|
||||
const { db, client } = await getConnection();
|
||||
|
||||
startTracing({ service: 'presence-service', db: client });
|
||||
|
||||
registerServiceModels(db, await getTrashCollection());
|
||||
|
||||
await cronJobs.start(db);
|
||||
|
||||
api.setBroker(startBroker());
|
||||
|
||||
// need to import Presence service after models are registered
|
||||
const { Presence } = await import('@rocket.chat/presence');
|
||||
|
||||
api.registerService(new Presence());
|
||||
|
||||
await api.start();
|
||||
|
||||
polka()
|
||||
.get('/health', async function (_req, res) {
|
||||
try {
|
||||
await api.nodeList();
|
||||
res.end('ok');
|
||||
} catch (err) {
|
||||
console.error('Service not healthy', err);
|
||||
|
||||
res.writeHead(500);
|
||||
res.end('not healthy');
|
||||
}
|
||||
})
|
||||
.listen(PORT);
|
||||
})();
|
||||
10
ee/apps/presence-service/tsconfig.json
Normal file
10
ee/apps/presence-service/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@rocket.chat/tsconfig/server.json",
|
||||
"compilerOptions": {
|
||||
"strictPropertyInitialization": false, // TODO: Remove this line
|
||||
"outDir": "./dist/ee/apps/presence-service/src",
|
||||
},
|
||||
"files": ["./src/service.ts", "./src/fips.ts"],
|
||||
"include": ["../../../apps/meteor/definition/externals/meteor"],
|
||||
"exclude": ["./dist"]
|
||||
}
|
||||
3929
ee/apps/queue-worker/CHANGELOG.md
Normal file
3929
ee/apps/queue-worker/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
56
ee/apps/queue-worker/package.json
Normal file
56
ee/apps/queue-worker/package.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "@rocket.chat/queue-worker",
|
||||
"version": "0.5.1",
|
||||
"private": true,
|
||||
"description": "Rocket.Chat service",
|
||||
"keywords": [
|
||||
"rocketchat"
|
||||
],
|
||||
"author": "Rocket.Chat",
|
||||
"main": "./dist/ee/apps/queue-worker/src/service.js",
|
||||
"files": [
|
||||
"/dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"lint": "eslint .",
|
||||
"ms": "TRANSPORTER=${TRANSPORTER:-TCP} MONGO_URL=${MONGO_URL:-mongodb://localhost:3001/meteor} ts-node --files src/service.ts",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rocket.chat/core-services": "workspace:^",
|
||||
"@rocket.chat/core-typings": "workspace:^",
|
||||
"@rocket.chat/emitter": "workspace:~",
|
||||
"@rocket.chat/logger": "workspace:^",
|
||||
"@rocket.chat/model-typings": "workspace:^",
|
||||
"@rocket.chat/models": "workspace:^",
|
||||
"@rocket.chat/network-broker": "workspace:^",
|
||||
"@rocket.chat/omnichannel-services": "workspace:^",
|
||||
"@rocket.chat/tracing": "workspace:^",
|
||||
"@types/node": "~24.13.2",
|
||||
"ejson": "^2.2.3",
|
||||
"eventemitter3": "^5.0.4",
|
||||
"mem": "^8.1.1",
|
||||
"moleculer": "^0.14.35",
|
||||
"moment-timezone": "^0.5.48",
|
||||
"mongo-message-queue": "^1.1.0",
|
||||
"mongodb": "6.16.0",
|
||||
"nats": "^2.28.2",
|
||||
"pino": "10.3.1",
|
||||
"polka": "^0.5.2",
|
||||
"prom-client": "^14.2.0",
|
||||
"prometheus-gc-stats": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rocket.chat/tsconfig": "workspace:*",
|
||||
"@types/polka": "^0.5.8",
|
||||
"@types/prometheus-gc-stats": "^0.6.4",
|
||||
"eslint": "~9.39.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"volta": {
|
||||
"extends": "../../../package.json"
|
||||
}
|
||||
}
|
||||
9
ee/apps/queue-worker/src/fips.ts
Normal file
9
ee/apps/queue-worker/src/fips.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
crypto.setFips(true);
|
||||
|
||||
if (!crypto.getFips()) {
|
||||
throw new Error('FIPS mode was not enabled after crypto.setFips(true)');
|
||||
}
|
||||
|
||||
console.log('FIPS COMPLIANCE CHECK: YES');
|
||||
39
ee/apps/queue-worker/src/service.ts
Normal file
39
ee/apps/queue-worker/src/service.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { api, getConnection, getTrashCollection } from '@rocket.chat/core-services';
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
import { registerServiceModels } from '@rocket.chat/models';
|
||||
import { startBroker } from '@rocket.chat/network-broker';
|
||||
import { startTracing } from '@rocket.chat/tracing';
|
||||
import polka from 'polka';
|
||||
|
||||
const PORT = process.env.PORT || 3038;
|
||||
|
||||
void (async () => {
|
||||
const { db, client } = await getConnection();
|
||||
|
||||
startTracing({ service: 'queue-worker', db: client });
|
||||
|
||||
registerServiceModels(db, await getTrashCollection());
|
||||
|
||||
api.setBroker(startBroker());
|
||||
|
||||
// need to import service after models are registeredpackagfe
|
||||
const { QueueWorker } = await import('@rocket.chat/omnichannel-services');
|
||||
|
||||
api.registerService(new QueueWorker(db, Logger));
|
||||
|
||||
await api.start();
|
||||
|
||||
polka()
|
||||
.get('/health', async function (_req, res) {
|
||||
try {
|
||||
await api.nodeList();
|
||||
res.end('ok');
|
||||
} catch (err) {
|
||||
console.error('Service not healthy', err);
|
||||
|
||||
res.writeHead(500);
|
||||
res.end('not healthy');
|
||||
}
|
||||
})
|
||||
.listen(PORT);
|
||||
})();
|
||||
10
ee/apps/queue-worker/tsconfig.json
Normal file
10
ee/apps/queue-worker/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@rocket.chat/tsconfig/server.json",
|
||||
"compilerOptions": {
|
||||
"strictPropertyInitialization": false,
|
||||
"outDir": "./dist/ee/apps/queue-worker/src",
|
||||
},
|
||||
"files": ["./src/service.ts", "./src/fips.ts"],
|
||||
"include": ["../../../apps/meteor/definition/externals/meteor"],
|
||||
"exclude": ["./dist"]
|
||||
}
|
||||
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"]
|
||||
}
|
||||
1
ee/packages/federation-matrix/.env.example
Normal file
1
ee/packages/federation-matrix/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
ENTERPRISE_LICENSE_RC1=
|
||||
2
ee/packages/federation-matrix/.gitignore
vendored
Normal file
2
ee/packages/federation-matrix/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.nyc_output
|
||||
logs/
|
||||
992
ee/packages/federation-matrix/CHANGELOG.md
Normal file
992
ee/packages/federation-matrix/CHANGELOG.md
Normal file
@@ -0,0 +1,992 @@
|
||||
# @rocket.chat/federation-matrix
|
||||
|
||||
## 0.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#41717](https://github.com/RocketChat/Rocket.Chat/pull/41717)) Fixes federation endpoints rejecting valid requests, which broke:
|
||||
|
||||
- room history backfill
|
||||
- image thumbnails
|
||||
- room message pagination
|
||||
- accepting an invite from another homeserver
|
||||
|
||||
- <details><summary>Updated dependencies [097884fb750bb68396a299230e2f20219dfe0e5a, 4947601bbf042cd1b2385f8f5dda438e608faea7, 0869925e52ca61a440a01a6646935b89af8c7aae, 18f5eb45d098ca64d403fdae4704211000efba29, b89a8d411ef65f6931a5fd1cd057740bc00cd9ba, 6dc66fbe33b7dae6e386e51dadb56b0a70994e80, 20528861308cee6561444fd970c9a080013a7ecf, 5deefe2766132f13118f38dc42cf1c9895208961, 8984df841e1ecc46198e073982adabc1f069c352, 7f9592dc92567993f41d0e3510ecc9c00637a4cc, a9f3e6cd3f31eac17bec626287fcf815d802e9b9, 742009a09148e33d141f50691cdec0f7e9818535]:</summary>
|
||||
|
||||
- @rocket.chat/rest-typings@8.8.0
|
||||
- @rocket.chat/core-typings@8.8.0
|
||||
- @rocket.chat/models@2.5.0
|
||||
- @rocket.chat/core-services@0.16.0
|
||||
- @rocket.chat/network-broker@0.2.39
|
||||
</details>
|
||||
|
||||
## 0.2.1-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#41717](https://github.com/RocketChat/Rocket.Chat/pull/41717)) Fixes federation endpoints rejecting valid requests, which broke:
|
||||
|
||||
- room history backfill
|
||||
- image thumbnails
|
||||
- room message pagination
|
||||
- accepting an invite from another homeserver
|
||||
|
||||
- <details><summary>Updated dependencies [097884fb750bb68396a299230e2f20219dfe0e5a, 4947601bbf042cd1b2385f8f5dda438e608faea7, 0869925e52ca61a440a01a6646935b89af8c7aae, 18f5eb45d098ca64d403fdae4704211000efba29, b89a8d411ef65f6931a5fd1cd057740bc00cd9ba, 6dc66fbe33b7dae6e386e51dadb56b0a70994e80, 20528861308cee6561444fd970c9a080013a7ecf, 5deefe2766132f13118f38dc42cf1c9895208961, 8984df841e1ecc46198e073982adabc1f069c352, 7f9592dc92567993f41d0e3510ecc9c00637a4cc, a9f3e6cd3f31eac17bec626287fcf815d802e9b9, 742009a09148e33d141f50691cdec0f7e9818535]:</summary>
|
||||
|
||||
- @rocket.chat/rest-typings@8.8.0-rc.0
|
||||
- @rocket.chat/core-typings@8.8.0-rc.0
|
||||
- @rocket.chat/models@2.5.0-rc.0
|
||||
- @rocket.chat/core-services@0.16.0-rc.0
|
||||
- @rocket.chat/license@1.2.0
|
||||
- @rocket.chat/emitter@0.33.0
|
||||
- @rocket.chat/http-router@7.9.23
|
||||
- @rocket.chat/network-broker@0.2.39-rc.0
|
||||
</details>
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#39324](https://github.com/RocketChat/Rocket.Chat/pull/39324)) Adds support for running Rocket.Chat in FIPS mode. The monolith and all microservices (ddp-streamer, account-service, authorization-service, presence-service, queue-worker, omnichannel-transcript) can now enforce FIPS-compliant cryptography via Node.js/OpenSSL FIPS, with dedicated FIPS Docker images. Running in FIPS mode requires a license including the new `fips` module, and FIPS status is now reported in server logs and statistics.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [c7aff48a40a9a78924cbf27fd38930c536ee11e5, 5f92f9a27dca70d506d919351612bd32dc04241a, 13b4a7b2dc203959b77b3b0c5f154d3e34fe2058, 4b34bd62f2ac8d51efd2f48caea7092e87f30ce7, 1bf84cbe288df03fc622fbddbc0e434bda291c2f, 4b57346a59b5c9433c25845c886be11af1bf71d4, 8d8cd01d0a4e6872ed543320c966efd52140e884, 6ebabce27a153d36ea8426f3257ffff880e820bc, ffe1b646226eeeda5a4d4697c831e568ec1eec64, 3cd7db677a72521439b564dca7a4ca6d6c3a1c07, 719e3db9734708e812ceb33483ffaa2e064b4d59, b2b5edf5b37be3ee070290553f6aec42c39c98f6, 74d6cacb77cc2b029dfd1a12890cfcae7fd540cb, 73c3aec49bacdfb42e67b6cbf90dfffbb65e0658, 73c3aec49bacdfb42e67b6cbf90dfffbb65e0658, 73c3aec49bacdfb42e67b6cbf90dfffbb65e0658, 73c3aec49bacdfb42e67b6cbf90dfffbb65e0658, 615ae2bf74bba0402e0151d9c0b8e4f8dd04cb17, 1cc7bbdef9330899a8207d3d55130f48321d68f1]:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.7.0
|
||||
- @rocket.chat/models@2.4.0
|
||||
- @rocket.chat/rest-typings@8.7.0
|
||||
- @rocket.chat/core-services@0.15.0
|
||||
- @rocket.chat/license@1.2.0
|
||||
- @rocket.chat/network-broker@0.2.38
|
||||
</details>
|
||||
|
||||
## 0.2.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#39324](https://github.com/RocketChat/Rocket.Chat/pull/39324)) Adds support for running Rocket.Chat in FIPS mode. The monolith and all microservices (ddp-streamer, account-service, authorization-service, presence-service, queue-worker, omnichannel-transcript) can now enforce FIPS-compliant cryptography via Node.js/OpenSSL FIPS, with dedicated FIPS Docker images. Running in FIPS mode requires a license including the new `fips` module, and FIPS status is now reported in server logs and statistics.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [c7aff48a40a9a78924cbf27fd38930c536ee11e5, 5f92f9a27dca70d506d919351612bd32dc04241a, 13b4a7b2dc203959b77b3b0c5f154d3e34fe2058, 4b34bd62f2ac8d51efd2f48caea7092e87f30ce7, 1bf84cbe288df03fc622fbddbc0e434bda291c2f, 4b57346a59b5c9433c25845c886be11af1bf71d4, 8d8cd01d0a4e6872ed543320c966efd52140e884, 6ebabce27a153d36ea8426f3257ffff880e820bc, ffe1b646226eeeda5a4d4697c831e568ec1eec64, 3cd7db677a72521439b564dca7a4ca6d6c3a1c07, 719e3db9734708e812ceb33483ffaa2e064b4d59, b2b5edf5b37be3ee070290553f6aec42c39c98f6, 74d6cacb77cc2b029dfd1a12890cfcae7fd540cb, 73c3aec49bacdfb42e67b6cbf90dfffbb65e0658, 73c3aec49bacdfb42e67b6cbf90dfffbb65e0658, 73c3aec49bacdfb42e67b6cbf90dfffbb65e0658, 73c3aec49bacdfb42e67b6cbf90dfffbb65e0658, 615ae2bf74bba0402e0151d9c0b8e4f8dd04cb17, 1cc7bbdef9330899a8207d3d55130f48321d68f1]:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.7.0-rc.0
|
||||
- @rocket.chat/models@2.4.0-rc.0
|
||||
- @rocket.chat/rest-typings@8.7.0-rc.0
|
||||
- @rocket.chat/core-services@0.15.0-rc.0
|
||||
- @rocket.chat/license@1.2.0-rc.0
|
||||
- @rocket.chat/network-broker@0.2.38-rc.0
|
||||
</details>
|
||||
|
||||
## 0.1.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#41046](https://github.com/RocketChat/Rocket.Chat/pull/41046)) Fixes an issue where editing or deleting a message in a federated room caused subsequent messages to stop syncing between servers
|
||||
|
||||
Note: this prevents the issue from happening, but does not restore rooms that are already affected. Recovering those requires a separate, one-time repair.
|
||||
|
||||
- ([#40839](https://github.com/RocketChat/Rocket.Chat/pull/40839)) Fixes an issue where `description` was incorrectly being used as alternative text for image attachments
|
||||
|
||||
- <details><summary>Updated dependencies [73e12e1707baea845395e0582892f65456598672, a7279cebc73edfa4b991eb593730c08e8f5e9001, 7380c44c751eff9ee624d80bf26370411ffed78b, f4f361234f00bd44efe348df4355e9d3cf80efe0, 9a36221f1fbf5ca417325204637c9f32fe760443, f57901d91feaccedd00dee65b78775b20235825b, f57901d91feaccedd00dee65b78775b20235825b, f57901d91feaccedd00dee65b78775b20235825b, 9a36221f1fbf5ca417325204637c9f32fe760443, 9a36221f1fbf5ca417325204637c9f32fe760443, fa685d0ddfdf1167705a58b5d846a993144e3734, 6bd9182ae1d914a55e70866db43e8d2038f7be28, 6fa5378a940cbc809800b3c7d7c0639810bb0ab8, f63b965f82b0ddc590c633706f7c31c8c5251b53]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.3.1
|
||||
- @rocket.chat/core-services@0.14.2
|
||||
- @rocket.chat/core-typings@8.6.0
|
||||
- @rocket.chat/rest-typings@8.6.0
|
||||
- @rocket.chat/network-broker@0.2.37
|
||||
</details>
|
||||
|
||||
## 0.1.6-rc.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#41046](https://github.com/RocketChat/Rocket.Chat/pull/41046)) Fixes an issue where editing or deleting a message in a federated room caused subsequent messages to stop syncing between servers
|
||||
|
||||
Note: this prevents the issue from happening, but does not restore rooms that are already affected. Recovering those requires a separate, one-time repair.
|
||||
|
||||
- <details><summary>Updated dependencies [6fa5378a940cbc809800b3c7d7c0639810bb0ab8]:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.6.0-rc.1
|
||||
- @rocket.chat/rest-typings@8.6.0-rc.1
|
||||
</details>
|
||||
|
||||
## 0.1.6-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#40839](https://github.com/RocketChat/Rocket.Chat/pull/40839)) Fixes an issue where `description` was incorrectly being used as alternative text for image attachments
|
||||
|
||||
- <details><summary>Updated dependencies [73e12e1707baea845395e0582892f65456598672, a7279cebc73edfa4b991eb593730c08e8f5e9001, 7380c44c751eff9ee624d80bf26370411ffed78b, f4f361234f00bd44efe348df4355e9d3cf80efe0, 9a36221f1fbf5ca417325204637c9f32fe760443, f57901d91feaccedd00dee65b78775b20235825b, f57901d91feaccedd00dee65b78775b20235825b, f57901d91feaccedd00dee65b78775b20235825b, 9a36221f1fbf5ca417325204637c9f32fe760443, 9a36221f1fbf5ca417325204637c9f32fe760443, fa685d0ddfdf1167705a58b5d846a993144e3734, 6bd9182ae1d914a55e70866db43e8d2038f7be28, f63b965f82b0ddc590c633706f7c31c8c5251b53]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.3.1-rc.0
|
||||
- @rocket.chat/core-services@0.14.2-rc.0
|
||||
- @rocket.chat/core-typings@8.6.0-rc.0
|
||||
- @rocket.chat/rest-typings@8.6.0-rc.0
|
||||
- @rocket.chat/network-broker@0.2.37-rc.0
|
||||
</details>
|
||||
|
||||
## 0.1.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#40938](https://github.com/RocketChat/Rocket.Chat/pull/40938) by [@dionisio-bot](https://github.com/dionisio-bot)) Fixes an issue where `description` was incorrectly being used as alternative text for image attachments
|
||||
|
||||
- <details><summary>Updated dependencies [01a184640f635866bf4c1c612696acc4eed62311]:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.5.1
|
||||
- @rocket.chat/rest-typings@8.5.1
|
||||
</details>
|
||||
|
||||
## 0.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [f7d47dd3517ec14ca2ec5c3c95fcdf9e1e2fb8b0, 4c3984593017d59edd631bf8ae4f35f9d3c3db36, b6b04aadfcc8558f888b334e37c46a77e5816237, 4704bf81ca370f120af32185a7c55407a26f8514, 12897e25d0dc25b7373f5264d38f38a5a7444257, e45585b70a3a7b75434c88e4b2ea9af0a0764a76, b1c2668b74bfb49ebaefe2f581b2f8be5d4d1dd6]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.3.0
|
||||
- @rocket.chat/rest-typings@8.5.0
|
||||
- @rocket.chat/core-typings@8.5.0
|
||||
- @rocket.chat/network-broker@0.2.36
|
||||
- @rocket.chat/core-services@0.14.1
|
||||
</details>
|
||||
|
||||
## 0.1.4-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [f7d47dd3517ec14ca2ec5c3c95fcdf9e1e2fb8b0, 4c3984593017d59edd631bf8ae4f35f9d3c3db36, ae9f740d6af20557eac61b4af902c868b4132b49, b6b04aadfcc8558f888b334e37c46a77e5816237, 4704bf81ca370f120af32185a7c55407a26f8514, 12897e25d0dc25b7373f5264d38f38a5a7444257, e45585b70a3a7b75434c88e4b2ea9af0a0764a76, b1c2668b74bfb49ebaefe2f581b2f8be5d4d1dd6]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.3.0-rc.0
|
||||
- @rocket.chat/rest-typings@8.5.0-rc.0
|
||||
- @rocket.chat/core-typings@8.5.0-rc.0
|
||||
- @rocket.chat/core-services@0.14.1-rc.0
|
||||
- @rocket.chat/network-broker@0.2.36-rc.0
|
||||
</details>
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#40114](https://github.com/RocketChat/Rocket.Chat/pull/40114)) Fixes a bug preventing users to join a room after being banned and unbanned.
|
||||
|
||||
- <details><summary>Updated dependencies [21cd54f87de5837fe6c999a44bd15be34b9fe905, 278b84f78360e53792a2e5d7620615039a0e15e9, 12c44d2db65af9f90c741da621164f5738fc19f5, 24b3671fe61b8b09c6a1b5dc6401b503b3fb92a0, 9713af36f5c0d673f2d2093015f322341706bab0, f3fa3ee2f2e53b777de9abd466f1b76a1ec1b96c, c544b805d1c03f8eae9d061cd48838206207a7c9, 4e99ff6c1a99ac569cf444f9de9b5fe5ac3262b2, 8c0e16ca29b393cfa50b425520db48ba5a74f678, f4dfb8ddc2049692371aeb084110b5768151b5df]:</summary>
|
||||
|
||||
- @rocket.chat/core-services@0.14.0
|
||||
- @rocket.chat/models@2.2.0
|
||||
- @rocket.chat/core-typings@8.4.0
|
||||
- @rocket.chat/rest-typings@8.4.0
|
||||
- @rocket.chat/network-broker@0.2.35
|
||||
- @rocket.chat/license@1.1.16
|
||||
- @rocket.chat/http-router@7.9.23
|
||||
</details>
|
||||
|
||||
## 0.1.3-rc.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.4.0-rc.2
|
||||
- @rocket.chat/rest-typings@8.4.0-rc.2
|
||||
- @rocket.chat/license@1.1.16-rc.2
|
||||
- @rocket.chat/core-services@0.14.0-rc.2
|
||||
- @rocket.chat/http-router@7.9.23-rc.2
|
||||
- @rocket.chat/models@2.2.0-rc.2
|
||||
- @rocket.chat/network-broker@0.2.35-rc.2
|
||||
</details>
|
||||
|
||||
## 0.1.3-rc.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-services@0.14.0-rc.1
|
||||
- @rocket.chat/network-broker@0.2.35-rc.1
|
||||
- @rocket.chat/core-typings@8.4.0-rc.1
|
||||
- @rocket.chat/rest-typings@8.4.0-rc.1
|
||||
- @rocket.chat/license@1.1.16-rc.1
|
||||
- @rocket.chat/http-router@7.9.23-rc.1
|
||||
- @rocket.chat/models@2.2.0-rc.1
|
||||
</details>
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-services@0.13.4
|
||||
- @rocket.chat/core-typings@8.3.2
|
||||
- @rocket.chat/rest-typings@8.3.2
|
||||
- @rocket.chat/license@1.1.15
|
||||
- @rocket.chat/http-router@7.9.22
|
||||
- @rocket.chat/models@2.1.4
|
||||
- @rocket.chat/network-broker@0.2.34
|
||||
</details>
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.3.1
|
||||
- @rocket.chat/rest-typings@8.3.1
|
||||
- @rocket.chat/license@1.1.14
|
||||
- @rocket.chat/core-services@0.13.3
|
||||
- @rocket.chat/http-router@7.9.21
|
||||
- @rocket.chat/models@2.1.3
|
||||
- @rocket.chat/network-broker@0.2.33
|
||||
</details>
|
||||
|
||||
## 0.1.3-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#40114](https://github.com/RocketChat/Rocket.Chat/pull/40114)) Fixes a bug preventing users to join a room after being banned and unbanned.
|
||||
|
||||
- <details><summary>Updated dependencies [21cd54f87de5837fe6c999a44bd15be34b9fe905, 278b84f78360e53792a2e5d7620615039a0e15e9, 12c44d2db65af9f90c741da621164f5738fc19f5, 24b3671fe61b8b09c6a1b5dc6401b503b3fb92a0, 9713af36f5c0d673f2d2093015f322341706bab0, f3fa3ee2f2e53b777de9abd466f1b76a1ec1b96c, c544b805d1c03f8eae9d061cd48838206207a7c9, 4e99ff6c1a99ac569cf444f9de9b5fe5ac3262b2, 8c0e16ca29b393cfa50b425520db48ba5a74f678, f4dfb8ddc2049692371aeb084110b5768151b5df]:</summary>
|
||||
|
||||
- @rocket.chat/core-services@0.14.0-rc.0
|
||||
- @rocket.chat/models@2.2.0-rc.0
|
||||
- @rocket.chat/core-typings@8.4.0-rc.0
|
||||
- @rocket.chat/rest-typings@8.4.0-rc.0
|
||||
- @rocket.chat/network-broker@0.2.33-rc.0
|
||||
- @rocket.chat/license@1.1.14-rc.0
|
||||
- @rocket.chat/http-router@7.9.21-rc.0
|
||||
</details>
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#39750](https://github.com/RocketChat/Rocket.Chat/pull/39750)) Adds support to name changes on federated rooms
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#39752](https://github.com/RocketChat/Rocket.Chat/pull/39752)) Fixes an issue on Federation where all domains ending with the pattern where being allowed to communicate, the feature is meant to work with a list, url by url
|
||||
|
||||
- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration
|
||||
|
||||
- <details><summary>Updated dependencies [602b20a8c570b895eb296ecfe39c9b7fcb12fabd, e2068892bf1ffc88b15ab71ad743cf84e5d31ed5, cd2fc208d351032c0b729755af4886665dca08b6, a4e3c1635d55ec4ce04cbde741426770e43581fb, 539659af22bc19880eda047dfc0b152472ccb65c, 5518503736b72674753e711ba4089d177ab988a5, a4341ec67d1f0413f30bbabfd292d1b0a41728b2, 40253146de8d8f83737e71b0ade7c67e0c295a28, 803b8075514de54c9ff34ba0c9aa3ee5fc3bbe61, 1361a1f4f1e3c0cc3f2a191cef8eccc12a714cde, 2a2701098536b32143003be8d267891978c708c9, 78e37dc3deae4ff05f5e33f9134c7094fd6c1330, 37acece030bc9f39bdaa86ab0130eb818332033e, d8baf395181b70fef9ce448eb509f65b66049615, ddc0ed34b03072362d166f1160104a9332b362e8, d83a1a9753464ee916845b3c88757bbcf76884a5, eae3fb3136bd0b48294c050a71b0a36d05ca02b0, 4c2e444216efd514ab406fe8e9cd127ef971d566, 722df6f60bc86c51b204e28a39acb3dc8710bdeb, 78b3fe3ef20e3a545b84551ba3f85cb40e862ba7, 98a6c58a38c053c60db2b4d53a9df0e94fecf0ba, 29b453e1def8092a8d78c28736e2bfb24229717b, 39f2e87e1caa6842e69155f033205cfdc4767b9e, c117492ad90d291a361eedc929506f557495caf7, 7c7324184589a15bf3e67b4f0c1cc222f8d48db3]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.1.2
|
||||
- @rocket.chat/rest-typings@8.3.0
|
||||
- @rocket.chat/network-broker@0.2.32
|
||||
- @rocket.chat/core-services@0.13.2
|
||||
- @rocket.chat/core-typings@8.3.0
|
||||
- @rocket.chat/http-router@7.9.20
|
||||
- @rocket.chat/license@1.1.13
|
||||
</details>
|
||||
|
||||
## 0.1.0-rc.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.3.0-rc.4
|
||||
- @rocket.chat/rest-typings@8.3.0-rc.4
|
||||
- @rocket.chat/license@1.1.13-rc.4
|
||||
- @rocket.chat/core-services@0.13.2-rc.4
|
||||
- @rocket.chat/http-router@7.9.20-rc.4
|
||||
- @rocket.chat/models@2.1.2-rc.4
|
||||
- @rocket.chat/network-broker@0.2.32-rc.4
|
||||
</details>
|
||||
|
||||
## 0.1.0-rc.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.3.0-rc.3
|
||||
- @rocket.chat/rest-typings@8.3.0-rc.3
|
||||
- @rocket.chat/license@1.1.13-rc.3
|
||||
- @rocket.chat/core-services@0.13.2-rc.3
|
||||
- @rocket.chat/http-router@7.9.20-rc.3
|
||||
- @rocket.chat/models@2.1.2-rc.3
|
||||
- @rocket.chat/network-broker@0.2.32-rc.3
|
||||
</details>
|
||||
|
||||
## 0.1.0-rc.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.3.0-rc.2
|
||||
- @rocket.chat/rest-typings@8.3.0-rc.2
|
||||
- @rocket.chat/license@1.1.13-rc.2
|
||||
- @rocket.chat/core-services@0.13.2-rc.2
|
||||
- @rocket.chat/http-router@7.9.20-rc.2
|
||||
- @rocket.chat/models@2.1.2-rc.2
|
||||
- @rocket.chat/network-broker@0.2.32-rc.2
|
||||
</details>
|
||||
|
||||
## 0.1.0-rc.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.3.0-rc.1
|
||||
- @rocket.chat/rest-typings@8.3.0-rc.1
|
||||
- @rocket.chat/license@1.1.13-rc.1
|
||||
- @rocket.chat/core-services@0.13.2-rc.1
|
||||
- @rocket.chat/http-router@7.9.20-rc.1
|
||||
- @rocket.chat/models@2.1.2-rc.1
|
||||
- @rocket.chat/network-broker@0.2.32-rc.1
|
||||
</details>
|
||||
|
||||
## 0.1.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#39750](https://github.com/RocketChat/Rocket.Chat/pull/39750)) Adds support to name changes on federated rooms
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#39752](https://github.com/RocketChat/Rocket.Chat/pull/39752)) Fixes an issue on Federation where all domains ending with the pattern where being allowed to communicate, the feature is meant to work with a list, url by url
|
||||
|
||||
- ([#38989](https://github.com/RocketChat/Rocket.Chat/pull/38989)) chore(eslint): Upgrades ESLint and its configuration
|
||||
|
||||
- <details><summary>Updated dependencies [602b20a8c570b895eb296ecfe39c9b7fcb12fabd, e2068892bf1ffc88b15ab71ad743cf84e5d31ed5, cd2fc208d351032c0b729755af4886665dca08b6, a4e3c1635d55ec4ce04cbde741426770e43581fb, 539659af22bc19880eda047dfc0b152472ccb65c, 5518503736b72674753e711ba4089d177ab988a5, a4341ec67d1f0413f30bbabfd292d1b0a41728b2, 40253146de8d8f83737e71b0ade7c67e0c295a28, 803b8075514de54c9ff34ba0c9aa3ee5fc3bbe61, 1361a1f4f1e3c0cc3f2a191cef8eccc12a714cde, 2a2701098536b32143003be8d267891978c708c9, 78e37dc3deae4ff05f5e33f9134c7094fd6c1330, 37acece030bc9f39bdaa86ab0130eb818332033e, d8baf395181b70fef9ce448eb509f65b66049615, ddc0ed34b03072362d166f1160104a9332b362e8, d83a1a9753464ee916845b3c88757bbcf76884a5, eae3fb3136bd0b48294c050a71b0a36d05ca02b0, 4c2e444216efd514ab406fe8e9cd127ef971d566, 722df6f60bc86c51b204e28a39acb3dc8710bdeb, 78b3fe3ef20e3a545b84551ba3f85cb40e862ba7, 98a6c58a38c053c60db2b4d53a9df0e94fecf0ba, 29b453e1def8092a8d78c28736e2bfb24229717b, 39f2e87e1caa6842e69155f033205cfdc4767b9e, c117492ad90d291a361eedc929506f557495caf7, 7c7324184589a15bf3e67b4f0c1cc222f8d48db3]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.1.2-rc.0
|
||||
- @rocket.chat/rest-typings@8.3.0-rc.0
|
||||
- @rocket.chat/network-broker@0.2.32-rc.0
|
||||
- @rocket.chat/core-services@0.13.2-rc.0
|
||||
- @rocket.chat/core-typings@8.3.0-rc.0
|
||||
- @rocket.chat/http-router@7.9.20-rc.0
|
||||
- @rocket.chat/license@1.1.13-rc.0
|
||||
</details>
|
||||
|
||||
## 0.0.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.2.1
|
||||
- @rocket.chat/rest-typings@8.2.1
|
||||
- @rocket.chat/license@1.1.12
|
||||
- @rocket.chat/core-services@0.13.1
|
||||
- @rocket.chat/http-router@7.9.19
|
||||
- @rocket.chat/models@2.1.1
|
||||
- @rocket.chat/network-broker@0.2.31
|
||||
</details>
|
||||
|
||||
## 0.0.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#38596](https://github.com/RocketChat/Rocket.Chat/pull/38596)) Adjusts the minimum supported MongoDB version from 8.2 (Rapid Release with short support lifecycle) to 8, ensuring stable and long-term compatibility
|
||||
|
||||
- <details><summary>Updated dependencies [d3758a7d57ab602745369ef9d2ccdbf9271cf305, fbc4935dec220495201cf905017170d3cd1e275c, e57f15845e4df048dd2f08f11aa08215780a2c34, 11e1c51f0867a35c69ce9b6eeca25dbbe2c71872, 88da141f3c2af6f91980c7ca8b8777161f99a068, 1c474580b768358b49c93002b1277e7065df02fe, 75d089ca40248af963d7cd2a8034c3c6de6b971e, 3b003e6b69c11b280d55bcc8db2f3e4ae7a4a573, 87faec13b3c0efc3e85627f9b70c4561b7231416, 508b4a17d76dc1cd7d3a55bdba826216f51432e2, 123aebec2caa74b17d2b5dcbd2a2db2e687cf3ac]:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.2.0
|
||||
- @rocket.chat/models@2.1.0
|
||||
- @rocket.chat/core-services@0.13.0
|
||||
- @rocket.chat/rest-typings@8.2.0
|
||||
- @rocket.chat/http-router@7.9.18
|
||||
- @rocket.chat/license@1.1.11
|
||||
- @rocket.chat/network-broker@0.2.30
|
||||
</details>
|
||||
|
||||
## 0.0.13-rc.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.2.0-rc.2
|
||||
- @rocket.chat/rest-typings@8.2.0-rc.2
|
||||
- @rocket.chat/license@1.1.11-rc.2
|
||||
- @rocket.chat/core-services@0.13.0-rc.2
|
||||
- @rocket.chat/http-router@7.9.18-rc.2
|
||||
- @rocket.chat/models@2.1.0-rc.2
|
||||
- @rocket.chat/network-broker@0.2.30-rc.2
|
||||
</details>
|
||||
|
||||
## 0.0.13-rc.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.2.0-rc.1
|
||||
- @rocket.chat/rest-typings@8.2.0-rc.1
|
||||
- @rocket.chat/license@1.1.11-rc.1
|
||||
- @rocket.chat/core-services@0.13.0-rc.1
|
||||
- @rocket.chat/http-router@7.9.18-rc.1
|
||||
- @rocket.chat/models@2.1.0-rc.1
|
||||
- @rocket.chat/network-broker@0.2.30-rc.1
|
||||
</details>
|
||||
|
||||
## 0.0.13-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#38596](https://github.com/RocketChat/Rocket.Chat/pull/38596)) Adjusts the minimum supported MongoDB version from 8.2 (Rapid Release with short support lifecycle) to 8, ensuring stable and long-term compatibility
|
||||
|
||||
- <details><summary>Updated dependencies [d3758a7d57ab602745369ef9d2ccdbf9271cf305, fbc4935dec220495201cf905017170d3cd1e275c, e57f15845e4df048dd2f08f11aa08215780a2c34, 11e1c51f0867a35c69ce9b6eeca25dbbe2c71872, 88da141f3c2af6f91980c7ca8b8777161f99a068, 1c474580b768358b49c93002b1277e7065df02fe, 75d089ca40248af963d7cd2a8034c3c6de6b971e, 3b003e6b69c11b280d55bcc8db2f3e4ae7a4a573, 87faec13b3c0efc3e85627f9b70c4561b7231416, 508b4a17d76dc1cd7d3a55bdba826216f51432e2, 123aebec2caa74b17d2b5dcbd2a2db2e687cf3ac]:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.2.0-rc.0
|
||||
- @rocket.chat/models@2.1.0-rc.0
|
||||
- @rocket.chat/core-services@0.13.0-rc.0
|
||||
- @rocket.chat/rest-typings@8.2.0-rc.0
|
||||
- @rocket.chat/http-router@7.9.18-rc.0
|
||||
- @rocket.chat/license@1.1.11-rc.0
|
||||
- @rocket.chat/network-broker@0.2.30-rc.0
|
||||
</details>
|
||||
|
||||
## 0.0.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.1.1
|
||||
- @rocket.chat/rest-typings@8.1.1
|
||||
- @rocket.chat/license@1.1.10
|
||||
- @rocket.chat/core-services@0.12.3
|
||||
- @rocket.chat/http-router@7.9.17
|
||||
- @rocket.chat/models@2.0.3
|
||||
- @rocket.chat/network-broker@0.2.29
|
||||
</details>
|
||||
|
||||
## 0.0.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [f4fa393fccb6abefbe6fb6550563e8ca21fc792d, 7b51ba84340480fa28e85c8f35d9d2f947d3250e, fd319d58a621d66c20029fd962ddb24a1a24ecc3, 5c3c52b1fc8b0bd52d4744614ad4d2b8e2e03937, 6654c5b481f91bdcb03d68ee0f3a12d58201137e, 020dfbcab6a940ca2e03d802d0f6b54714a34aa2, 5fa150953b86ff36face25083ed49e8c97a8044d, 05c415b94cb91907de39a39c6d277579258f334e, ccd76942bf74322f21deb855af5348856463b82a]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.0.2
|
||||
- @rocket.chat/core-services@0.12.2
|
||||
- @rocket.chat/rest-typings@8.1.0
|
||||
- @rocket.chat/core-typings@8.1.0
|
||||
- @rocket.chat/license@1.1.9
|
||||
- @rocket.chat/http-router@7.9.16
|
||||
- @rocket.chat/network-broker@0.2.28
|
||||
</details>
|
||||
|
||||
## 0.0.11-rc.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-services@0.12.2-rc.2
|
||||
- @rocket.chat/core-typings@8.1.0-rc.2
|
||||
- @rocket.chat/rest-typings@8.1.0-rc.2
|
||||
- @rocket.chat/network-broker@0.2.28-rc.2
|
||||
- @rocket.chat/license@1.1.9-rc.2
|
||||
- @rocket.chat/http-router@7.9.16-rc.2
|
||||
- @rocket.chat/models@2.0.2-rc.2
|
||||
</details>
|
||||
|
||||
## 0.0.11-rc.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [fd319d58a621d66c20029fd962ddb24a1a24ecc3]:</summary>
|
||||
|
||||
- @rocket.chat/rest-typings@8.1.0-rc.1
|
||||
- @rocket.chat/core-services@0.12.1-rc.1
|
||||
- @rocket.chat/http-router@7.9.15-rc.1
|
||||
- @rocket.chat/models@2.0.1-rc.1
|
||||
- @rocket.chat/network-broker@0.2.27-rc.1
|
||||
- @rocket.chat/core-typings@8.1.0-rc.1
|
||||
- @rocket.chat/license@1.1.8-rc.1
|
||||
</details>
|
||||
|
||||
## 0.0.11-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [f4fa393fccb6abefbe6fb6550563e8ca21fc792d, 7b51ba84340480fa28e85c8f35d9d2f947d3250e, 5c3c52b1fc8b0bd52d4744614ad4d2b8e2e03937, 6654c5b481f91bdcb03d68ee0f3a12d58201137e, 020dfbcab6a940ca2e03d802d0f6b54714a34aa2, 5fa150953b86ff36face25083ed49e8c97a8044d, 05c415b94cb91907de39a39c6d277579258f334e, ccd76942bf74322f21deb855af5348856463b82a]:</summary>
|
||||
|
||||
- @rocket.chat/models@2.0.1-rc.0
|
||||
- @rocket.chat/core-services@0.12.1-rc.0
|
||||
- @rocket.chat/rest-typings@8.1.0-rc.0
|
||||
- @rocket.chat/core-typings@8.1.0-rc.0
|
||||
- @rocket.chat/license@1.1.8-rc.0
|
||||
- @rocket.chat/http-router@7.9.15-rc.0
|
||||
- @rocket.chat/network-broker@0.2.27-rc.0
|
||||
</details>
|
||||
|
||||
## 0.0.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.0.1
|
||||
- @rocket.chat/rest-typings@8.0.1
|
||||
- @rocket.chat/license@1.1.8
|
||||
- @rocket.chat/core-services@0.12.1
|
||||
- @rocket.chat/http-router@7.9.15
|
||||
- @rocket.chat/models@2.0.1
|
||||
- @rocket.chat/network-broker@0.2.27
|
||||
</details>
|
||||
|
||||
## 0.0.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#37791](https://github.com/RocketChat/Rocket.Chat/pull/37791)) Fixes an issue where cases of invites that were canceled or disinvited were not being handled.
|
||||
|
||||
- ([#37677](https://github.com/RocketChat/Rocket.Chat/pull/37677)) Fixes an issue where membership updates were not reflected when the user was the first member on their own server.
|
||||
|
||||
- <details><summary>Updated dependencies [347b8f973440f3e2239f79c00c2d9b430859eef2, b802430fbfdc7fa69a976468dc6dee6f3c4de26f, 872da49986436d2efa65fc42e416b45d706fd59c, 176d5eae3fb249d7d20c3e260d9fadc1a56a2fca, ac11ea05ffadeca978c794ff38d5199d9acb2c29, 1baa03cced8f94584da1224ad59cad86f219707a, f3f0b273ab49e9d1048ba43d52eb36005274905e, ac11ea05ffadeca978c794ff38d5199d9acb2c29, a1d65f493fdb039d34ef4f65d243a97931763f4b, ddc935727e9a7275813006d9dcaa7fe866610844, a5a7343a835b04812c70699be1b13e54f0e10d48, 73d9eb2783176954f42aa2cbeda8abf1d49ac260, 476a070b0099b95e4c463ee85960c4dcfbd87120, 239f4b1171bcf448cfba345cc90c4b5cd7c21afc, dc67590d14d510b069dee074c55314c56f74bb11, be80b724a636877294b5e5baa501d070941131dd, 4aa3634186d97f4144c39f6b42a65107d3d30df0, dccdcc5b4a0da4814f72a020bc4eccb8ea2497d8, 5ac1863be4c6e82666989f4b569928c0805691ff, ec0f8b435dd12c218adffa8892737c7ced4debb8, cb3c5e3455606a045f95f168dae6ed32a387697c, 5b3f93c47a03b628d613a77005e92021cd6cee4b]:</summary>
|
||||
|
||||
- @rocket.chat/rest-typings@8.0.0
|
||||
- @rocket.chat/core-typings@8.0.0
|
||||
- @rocket.chat/models@2.0.0
|
||||
- @rocket.chat/core-services@0.12.0
|
||||
- @rocket.chat/http-router@7.9.14
|
||||
- @rocket.chat/license@1.1.7
|
||||
- @rocket.chat/network-broker@0.2.26
|
||||
</details>
|
||||
|
||||
## 0.0.9-rc.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.0.0-rc.5
|
||||
- @rocket.chat/rest-typings@8.0.0-rc.5
|
||||
- @rocket.chat/license@1.1.7-rc.5
|
||||
- @rocket.chat/core-services@0.12.0-rc.5
|
||||
- @rocket.chat/http-router@7.9.14-rc.5
|
||||
- @rocket.chat/models@2.0.0-rc.5
|
||||
- @rocket.chat/network-broker@0.2.26-rc.5
|
||||
</details>
|
||||
|
||||
## 0.0.9-rc.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.0.0-rc.4
|
||||
- @rocket.chat/rest-typings@8.0.0-rc.4
|
||||
- @rocket.chat/license@1.1.7-rc.4
|
||||
- @rocket.chat/core-services@0.12.0-rc.4
|
||||
- @rocket.chat/http-router@7.9.14-rc.4
|
||||
- @rocket.chat/models@2.0.0-rc.4
|
||||
- @rocket.chat/network-broker@0.2.26-rc.4
|
||||
</details>
|
||||
|
||||
## 0.0.9-rc.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-services@0.12.0-rc.3
|
||||
- @rocket.chat/network-broker@0.2.26-rc.3
|
||||
- @rocket.chat/core-typings@8.0.0-rc.3
|
||||
- @rocket.chat/rest-typings@8.0.0-rc.3
|
||||
- @rocket.chat/license@1.1.7-rc.3
|
||||
- @rocket.chat/http-router@7.9.14-rc.3
|
||||
- @rocket.chat/models@2.0.0-rc.3
|
||||
</details>
|
||||
|
||||
## 0.0.9-rc.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.0.0-rc.2
|
||||
- @rocket.chat/rest-typings@8.0.0-rc.2
|
||||
- @rocket.chat/license@1.1.7-rc.2
|
||||
- @rocket.chat/core-services@0.12.0-rc.2
|
||||
- @rocket.chat/http-router@7.9.14-rc.2
|
||||
- @rocket.chat/models@2.0.0-rc.2
|
||||
- @rocket.chat/network-broker@0.2.26-rc.2
|
||||
</details>
|
||||
|
||||
## 0.0.9-rc.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@8.0.0-rc.1
|
||||
- @rocket.chat/rest-typings@8.0.0-rc.1
|
||||
- @rocket.chat/license@1.1.7-rc.1
|
||||
- @rocket.chat/core-services@0.12.0-rc.1
|
||||
- @rocket.chat/http-router@7.9.14-rc.1
|
||||
- @rocket.chat/models@2.0.0-rc.1
|
||||
- @rocket.chat/network-broker@0.2.26-rc.1
|
||||
</details>
|
||||
|
||||
## 0.0.9-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ([#37791](https://github.com/RocketChat/Rocket.Chat/pull/37791)) Fixes an issue where cases of invites that were canceled or disinvited were not being handled.
|
||||
|
||||
- ([#37677](https://github.com/RocketChat/Rocket.Chat/pull/37677)) Fixes an issue where membership updates were not reflected when the user was the first member on their own server.
|
||||
|
||||
- <details><summary>Updated dependencies [347b8f973440f3e2239f79c00c2d9b430859eef2, b802430fbfdc7fa69a976468dc6dee6f3c4de26f, 872da49986436d2efa65fc42e416b45d706fd59c, 176d5eae3fb249d7d20c3e260d9fadc1a56a2fca, ac11ea05ffadeca978c794ff38d5199d9acb2c29, 1baa03cced8f94584da1224ad59cad86f219707a, f3f0b273ab49e9d1048ba43d52eb36005274905e, ac11ea05ffadeca978c794ff38d5199d9acb2c29, a1d65f493fdb039d34ef4f65d243a97931763f4b, ddc935727e9a7275813006d9dcaa7fe866610844, a5a7343a835b04812c70699be1b13e54f0e10d48, 73d9eb2783176954f42aa2cbeda8abf1d49ac260, 476a070b0099b95e4c463ee85960c4dcfbd87120, 239f4b1171bcf448cfba345cc90c4b5cd7c21afc, dc67590d14d510b069dee074c55314c56f74bb11, be80b724a636877294b5e5baa501d070941131dd, 4aa3634186d97f4144c39f6b42a65107d3d30df0, dccdcc5b4a0da4814f72a020bc4eccb8ea2497d8, 5ac1863be4c6e82666989f4b569928c0805691ff, ec0f8b435dd12c218adffa8892737c7ced4debb8, cb3c5e3455606a045f95f168dae6ed32a387697c, 5b3f93c47a03b628d613a77005e92021cd6cee4b]:</summary>
|
||||
|
||||
- @rocket.chat/rest-typings@8.0.0-rc.0
|
||||
- @rocket.chat/core-typings@8.0.0-rc.0
|
||||
- @rocket.chat/models@2.0.0-rc.0
|
||||
- @rocket.chat/core-services@0.12.0-rc.0
|
||||
- @rocket.chat/http-router@7.9.14-rc.0
|
||||
- @rocket.chat/license@1.1.7-rc.0
|
||||
- @rocket.chat/network-broker@0.2.26-rc.0
|
||||
</details>
|
||||
|
||||
## 0.0.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [eef2b390a8e4664ad4b974e7ea579982245efce5]:</summary>
|
||||
|
||||
- @rocket.chat/core-services@0.11.2
|
||||
- @rocket.chat/network-broker@0.2.25
|
||||
- @rocket.chat/core-typings@7.13.2
|
||||
- @rocket.chat/rest-typings@7.13.2
|
||||
- @rocket.chat/license@1.1.6
|
||||
- @rocket.chat/http-router@7.9.13
|
||||
- @rocket.chat/models@1.8.2
|
||||
</details>
|
||||
|
||||
## 0.0.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@7.13.1
|
||||
- @rocket.chat/rest-typings@7.13.1
|
||||
- @rocket.chat/license@1.1.5
|
||||
- @rocket.chat/core-services@0.11.1
|
||||
- @rocket.chat/http-router@7.9.12
|
||||
- @rocket.chat/models@1.8.1
|
||||
- @rocket.chat/network-broker@0.2.24
|
||||
</details>
|
||||
|
||||
## 0.0.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [8596daf01ac84864caa63dd937971e557933d401, 83642cbe499ba399329449fb8cd652a0405c0795, 7f1b834a55b1240c226afde77713262da47f45dc, 5c7e8ec1de894e7b8eeb6e57b0c8a43bd22d2d46, 65fbcbed9f64004b953dd9d4182b3fccb8147339, 035e0c7c387d6c99703513c7c00bf37e86538b71]:</summary>
|
||||
|
||||
- @rocket.chat/core-services@0.11.0
|
||||
- @rocket.chat/models@1.8.0
|
||||
- @rocket.chat/core-typings@7.13.0
|
||||
- @rocket.chat/rest-typings@7.13.0
|
||||
- @rocket.chat/network-broker@0.2.23
|
||||
- @rocket.chat/license@1.1.4
|
||||
- @rocket.chat/http-router@7.9.11
|
||||
</details>
|
||||
|
||||
## 0.0.6-rc.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@7.13.0-rc.2
|
||||
- @rocket.chat/rest-typings@7.13.0-rc.2
|
||||
- @rocket.chat/license@1.1.4-rc.2
|
||||
- @rocket.chat/core-services@0.11.0-rc.2
|
||||
- @rocket.chat/http-router@7.9.11-rc.2
|
||||
- @rocket.chat/models@1.8.0-rc.2
|
||||
- @rocket.chat/network-broker@0.2.23-rc.2
|
||||
</details>
|
||||
|
||||
## 0.0.6-rc.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
- @rocket.chat/core-typings@7.13.0-rc.1
|
||||
- @rocket.chat/rest-typings@7.13.0-rc.1
|
||||
- @rocket.chat/license@1.1.2-rc.1
|
||||
- @rocket.chat/core-services@0.11.0-rc.1
|
||||
- @rocket.chat/http-router@7.9.9-rc.1
|
||||
- @rocket.chat/models@1.8.0-rc.1
|
||||
- @rocket.chat/network-broker@0.2.21-rc.1
|
||||
</details>
|
||||
|
||||
## 0.0.6-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [8596daf01ac84864caa63dd937971e557933d401, 83642cbe499ba399329449fb8cd652a0405c0795, 7f1b834a55b1240c226afde77713262da47f45dc, 5c7e8ec1de894e7b8eeb6e57b0c8a43bd22d2d46, 65fbcbed9f64004b953dd9d4182b3fccb8147339, 035e0c7c387d6c99703513c7c00bf37e86538b71]:</summary>
|
||||
|
||||
- @rocket.chat/core-services@0.11.0-rc.0
|
||||
- @rocket.chat/models@1.8.0-rc.0
|
||||
- @rocket.chat/core-typings@7.13.0-rc.0
|
||||
- @rocket.chat/rest-typings@7.13.0-rc.0
|
||||
- @rocket.chat/network-broker@0.2.21-rc.0
|
||||
- @rocket.chat/license@1.1.2-rc.0
|
||||
- @rocket.chat/http-router@7.9.9-rc.0
|
||||
</details>
|
||||
|
||||
## 0.0.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@7.12.2
|
||||
- @rocket.chat/rest-typings@7.12.2
|
||||
- @rocket.chat/license@1.1.3
|
||||
- @rocket.chat/core-services@0.10.6
|
||||
- @rocket.chat/http-router@7.9.10
|
||||
- @rocket.chat/models@1.7.3
|
||||
- @rocket.chat/network-broker@0.2.22
|
||||
</details>
|
||||
|
||||
## 0.0.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [8cbd9bfd0566cbd7d86d8b40ea7d58d6ef382742]:</summary>
|
||||
|
||||
- @rocket.chat/models@1.7.2
|
||||
- @rocket.chat/core-services@0.10.5
|
||||
- @rocket.chat/core-typings@7.12.1
|
||||
- @rocket.chat/rest-typings@7.12.1
|
||||
- @rocket.chat/network-broker@0.2.21
|
||||
- @rocket.chat/license@1.1.2
|
||||
- @rocket.chat/http-router@7.9.9
|
||||
</details>
|
||||
|
||||
## 0.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [48d1442a07e860fefd6991c97e94d6a9de345afe, d166e2a1ffba4e59361d5f79e8c376fca5cbf12f, 4ab2b7fdb9314538d45abdf4474ea9d2409799be, fd4f9b2523dae23518561cdcfa8d931f87d8964d]:</summary>
|
||||
|
||||
- @rocket.chat/rest-typings@7.12.0
|
||||
- @rocket.chat/core-typings@7.12.0
|
||||
- @rocket.chat/core-services@0.10.4
|
||||
- @rocket.chat/http-router@7.9.8
|
||||
- @rocket.chat/models@1.7.1
|
||||
- @rocket.chat/license@1.1.1
|
||||
- @rocket.chat/network-broker@0.2.20
|
||||
</details>
|
||||
|
||||
## 0.0.3-rc.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@7.12.0-rc.4
|
||||
- @rocket.chat/rest-typings@7.12.0-rc.4
|
||||
- @rocket.chat/license@1.1.1-rc.4
|
||||
- @rocket.chat/core-services@0.10.4-rc.4
|
||||
- @rocket.chat/http-router@7.9.8-rc.4
|
||||
- @rocket.chat/models@1.7.1-rc.4
|
||||
- @rocket.chat/network-broker@0.2.20-rc.4
|
||||
</details>
|
||||
|
||||
## 0.0.3-rc.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@7.12.0-rc.3
|
||||
- @rocket.chat/rest-typings@7.12.0-rc.3
|
||||
- @rocket.chat/license@1.1.1-rc.3
|
||||
- @rocket.chat/core-services@0.10.4-rc.3
|
||||
- @rocket.chat/http-router@7.9.8-rc.3
|
||||
- @rocket.chat/models@1.7.1-rc.3
|
||||
- @rocket.chat/network-broker@0.2.20-rc.3
|
||||
</details>
|
||||
|
||||
## 0.0.3-rc.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@7.12.0-rc.2
|
||||
- @rocket.chat/rest-typings@7.12.0-rc.2
|
||||
- @rocket.chat/license@1.1.1-rc.2
|
||||
- @rocket.chat/core-services@0.10.4-rc.2
|
||||
- @rocket.chat/http-router@7.9.8-rc.2
|
||||
- @rocket.chat/models@1.7.1-rc.2
|
||||
- @rocket.chat/network-broker@0.2.20-rc.2
|
||||
</details>
|
||||
|
||||
## 0.0.3-rc.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@7.12.0-rc.1
|
||||
- @rocket.chat/rest-typings@7.12.0-rc.1
|
||||
- @rocket.chat/license@1.1.1-rc.1
|
||||
- @rocket.chat/core-services@0.10.4-rc.1
|
||||
- @rocket.chat/http-router@7.9.8-rc.1
|
||||
- @rocket.chat/models@1.7.1-rc.1
|
||||
- @rocket.chat/network-broker@0.2.20-rc.1
|
||||
</details>
|
||||
|
||||
## 0.0.3-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [48d1442a07e860fefd6991c97e94d6a9de345afe, d166e2a1ffba4e59361d5f79e8c376fca5cbf12f, 4ab2b7fdb9314538d45abdf4474ea9d2409799be, fd4f9b2523dae23518561cdcfa8d931f87d8964d]:</summary>
|
||||
|
||||
- @rocket.chat/rest-typings@7.12.0-rc.0
|
||||
- @rocket.chat/core-typings@7.12.0-rc.0
|
||||
- @rocket.chat/core-services@0.10.4-rc.0
|
||||
- @rocket.chat/http-router@7.9.8-rc.0
|
||||
- @rocket.chat/models@1.7.1-rc.0
|
||||
- @rocket.chat/license@1.1.1-rc.0
|
||||
- @rocket.chat/network-broker@0.2.20-rc.0
|
||||
</details>
|
||||
|
||||
## 0.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [c102712222cc18c050f45f9279f8eafcff3e7e8a, d76a5578ed0e14fae2a041c0e09d565b28630d76, 8d2ad40c46334aaf35816e52a3b28dc2ac23ce2d, ba793542644d2b725924c4cef253e7d84bc2ff71, 3e177dbd0b65d70bce8587287ba73b30170eb0f7, c5ee569cc3120bb616001fd78687dbf623a6a504, 00611aceec7f158064863f2e829c5a6698687512, c15a47ee1d55118342d0f0ece7af77f6f41132b7, 3dfb557f723a395bc1e00f2647675968c46656bc, 276c270f34293729182e9ebab61e1ff2c35398fc, b0a4602a4461200b9872b2b073ec56fa55ecb466, 80fbb2eb421253e6066207949eaf2b41871e4428, 90a1759df57814306cbc859409fe041b33db7a39, b74ab6f18ff1343f4835975f663211b005ccc5a1]:</summary>
|
||||
|
||||
- @rocket.chat/models@1.7.0
|
||||
- @rocket.chat/rest-typings@7.11.0
|
||||
- @rocket.chat/core-services@0.10.3
|
||||
- @rocket.chat/core-typings@7.11.0
|
||||
- @rocket.chat/license@1.1.0
|
||||
- @rocket.chat/http-router@7.9.7
|
||||
- @rocket.chat/network-broker@0.2.19
|
||||
</details>
|
||||
|
||||
## 0.0.2-rc.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@7.11.0-rc.7
|
||||
- @rocket.chat/rest-typings@7.11.0-rc.7
|
||||
- @rocket.chat/license@1.1.0-rc.7
|
||||
- @rocket.chat/core-services@0.10.3-rc.7
|
||||
- @rocket.chat/http-router@7.9.7-rc.7
|
||||
- @rocket.chat/models@1.7.0-rc.7
|
||||
- @rocket.chat/network-broker@0.2.19-rc.7
|
||||
</details>
|
||||
|
||||
## 0.0.2-rc.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@7.11.0-rc.6
|
||||
- @rocket.chat/rest-typings@7.11.0-rc.6
|
||||
- @rocket.chat/license@1.1.0-rc.6
|
||||
- @rocket.chat/core-services@0.10.2-rc.6
|
||||
- @rocket.chat/http-router@7.9.6-rc.6
|
||||
- @rocket.chat/models@1.7.0-rc.6
|
||||
- @rocket.chat/network-broker@0.2.18-rc.6
|
||||
</details>
|
||||
|
||||
## 0.0.2-rc.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@7.11.0-rc.5
|
||||
- @rocket.chat/rest-typings@7.11.0-rc.5
|
||||
- @rocket.chat/license@1.1.0-rc.5
|
||||
- @rocket.chat/core-services@0.10.2-rc.5
|
||||
- @rocket.chat/http-router@7.9.6-rc.5
|
||||
- @rocket.chat/models@1.7.0-rc.5
|
||||
- @rocket.chat/network-broker@0.2.18-rc.5
|
||||
</details>
|
||||
|
||||
## 0.0.2-rc.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@7.11.0-rc.4
|
||||
- @rocket.chat/rest-typings@7.11.0-rc.4
|
||||
- @rocket.chat/license@1.1.0-rc.4
|
||||
- @rocket.chat/core-services@0.10.2-rc.4
|
||||
- @rocket.chat/http-router@7.9.6-rc.4
|
||||
- @rocket.chat/models@1.7.0-rc.4
|
||||
- @rocket.chat/network-broker@0.2.18-rc.4
|
||||
</details>
|
||||
|
||||
## 0.0.2-rc.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@7.11.0-rc.3
|
||||
- @rocket.chat/rest-typings@7.11.0-rc.3
|
||||
- @rocket.chat/license@1.1.0-rc.3
|
||||
- @rocket.chat/core-services@0.10.2-rc.3
|
||||
- @rocket.chat/http-router@7.9.6-rc.3
|
||||
- @rocket.chat/models@1.7.0-rc.3
|
||||
- @rocket.chat/network-broker@0.2.18-rc.3
|
||||
</details>
|
||||
|
||||
## 0.0.2-rc.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@7.11.0-rc.2
|
||||
- @rocket.chat/rest-typings@7.11.0-rc.2
|
||||
- @rocket.chat/license@1.1.0-rc.2
|
||||
- @rocket.chat/core-services@0.10.1-rc.2
|
||||
- @rocket.chat/http-router@7.9.5-rc.2
|
||||
- @rocket.chat/models@1.7.0-rc.2
|
||||
- @rocket.chat/network-broker@0.2.17-rc.2
|
||||
</details>
|
||||
|
||||
## 0.0.2-rc.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies []:</summary>
|
||||
|
||||
- @rocket.chat/core-typings@7.11.0-rc.1
|
||||
- @rocket.chat/rest-typings@7.11.0-rc.1
|
||||
- @rocket.chat/license@1.1.0-rc.1
|
||||
- @rocket.chat/core-services@0.10.1-rc.1
|
||||
- @rocket.chat/http-router@7.9.5-rc.1
|
||||
- @rocket.chat/models@1.7.0-rc.1
|
||||
- @rocket.chat/network-broker@0.2.17-rc.1
|
||||
</details>
|
||||
|
||||
## 0.0.2-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- <details><summary>Updated dependencies [c102712222cc18c050f45f9279f8eafcff3e7e8a, d76a5578ed0e14fae2a041c0e09d565b28630d76, 8d2ad40c46334aaf35816e52a3b28dc2ac23ce2d, ba793542644d2b725924c4cef253e7d84bc2ff71, 3e177dbd0b65d70bce8587287ba73b30170eb0f7, c5ee569cc3120bb616001fd78687dbf623a6a504, 00611aceec7f158064863f2e829c5a6698687512, c15a47ee1d55118342d0f0ece7af77f6f41132b7, 3dfb557f723a395bc1e00f2647675968c46656bc, 276c270f34293729182e9ebab61e1ff2c35398fc, b0a4602a4461200b9872b2b073ec56fa55ecb466, 80fbb2eb421253e6066207949eaf2b41871e4428, 90a1759df57814306cbc859409fe041b33db7a39, b74ab6f18ff1343f4835975f663211b005ccc5a1]:</summary>
|
||||
|
||||
- @rocket.chat/models@1.7.0-rc.0
|
||||
- @rocket.chat/rest-typings@7.11.0-rc.0
|
||||
- @rocket.chat/core-services@0.10.1-rc.0
|
||||
- @rocket.chat/core-typings@7.11.0-rc.0
|
||||
- @rocket.chat/license@1.1.0-rc.0
|
||||
- @rocket.chat/http-router@7.9.5-rc.0
|
||||
- @rocket.chat/network-broker@0.2.17-rc.0
|
||||
</details>
|
||||
76
ee/packages/federation-matrix/README.md
Normal file
76
ee/packages/federation-matrix/README.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Federation Matrix
|
||||
|
||||
Rocket.Chat's Matrix federation integration package for cross-platform communication.
|
||||
|
||||
## Integration Tests
|
||||
|
||||
### Setup
|
||||
|
||||
Before running integration tests, add the following entries to your `/etc/hosts` file:
|
||||
|
||||
```
|
||||
127.0.0.1 element
|
||||
127.0.0.1 hs1
|
||||
127.0.0.1 rc1
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
The integration test script builds Rocket.Chat locally, starts federation services (Rocket.Chat, Synapse, MongoDB), waits for all services to be ready, then runs end-to-end tests. The script automatically handles cleanup unless you specify otherwise.
|
||||
|
||||
### Available Flags
|
||||
|
||||
- **No flags (default)**: Builds local code and runs tests
|
||||
- `--image [IMAGE]`: Uses a pre-built Docker image instead of building locally (defaults to `rocketchat/rocket.chat:latest` if no image specified)
|
||||
- `--keep-running`: Keeps containers running after tests complete for manual validation
|
||||
- `--element`: Includes Element web client in the test environment
|
||||
- `--no-test`: Starts containers and skips running tests (useful for manual testing or debugging)
|
||||
|
||||
### Usage Examples
|
||||
|
||||
**Basic local testing:**
|
||||
```bash
|
||||
yarn test:integration
|
||||
```
|
||||
|
||||
**Test with pre-built image:**
|
||||
```bash
|
||||
yarn test:integration --image
|
||||
```
|
||||
|
||||
**Test with specific pre-built image:**
|
||||
```bash
|
||||
yarn test:integration --image rocketchat/rocket.chat:latest
|
||||
```
|
||||
|
||||
**Keep services running for manual inspection:**
|
||||
```bash
|
||||
yarn test:integration --keep-running
|
||||
```
|
||||
|
||||
**Run with Element client:**
|
||||
```bash
|
||||
yarn test:integration --element
|
||||
```
|
||||
|
||||
**Start containers only (skip tests):**
|
||||
```bash
|
||||
yarn test:integration --no-test
|
||||
```
|
||||
|
||||
**Start containers with Element and keep them running (skip tests):**
|
||||
```bash
|
||||
yarn test:integration --keep-running --element --no-test
|
||||
```
|
||||
|
||||
**Combine flags:**
|
||||
```bash
|
||||
yarn test:integration --image rocketchat/rocket.chat:latest --keep-running --element
|
||||
```
|
||||
|
||||
### Service URLs (when using --keep-running or --no-test)
|
||||
|
||||
- **Rocket.Chat**: https://rc1
|
||||
- **Synapse**: https://hs1
|
||||
- **MongoDB**: localhost:27017
|
||||
- **Element**: https://element (when using --element flag)
|
||||
165
ee/packages/federation-matrix/docker-compose.test.yml
Normal file
165
ee/packages/federation-matrix/docker-compose.test.yml
Normal file
@@ -0,0 +1,165 @@
|
||||
networks:
|
||||
hs1-net:
|
||||
rc1-net:
|
||||
element-net:
|
||||
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.6.6
|
||||
profiles:
|
||||
- test
|
||||
- element
|
||||
command:
|
||||
- '--api.insecure=true'
|
||||
# - "--log.level=DEBUG"
|
||||
- '--providers.docker=true'
|
||||
- '--providers.docker.exposedbydefault=false'
|
||||
- '--entrypoints.web.address=:80'
|
||||
- '--entrypoints.websecure.address=:443'
|
||||
ports:
|
||||
- '80:80'
|
||||
- '443:443'
|
||||
- '8080:8080'
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./docker-compose/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
|
||||
- ./docker-compose/traefik/dynamic_conf.yml:/etc/traefik/dynamic_conf.yml:ro
|
||||
- ./docker-compose/traefik/certs:/etc/traefik/certs:ro
|
||||
# - ./traefik-logs:/logs # Mount a volume for logs
|
||||
networks:
|
||||
# Defines and isolate one network per service to prevent inter service communication which
|
||||
# would not happen in real life. The name clashing between the host and service in the same
|
||||
# network (like rc1 service provided as rc1 host) is causing a bug on home server address
|
||||
# resolution which tries to communicate with the container directly some times and which does
|
||||
# not provide SSL neither the correct exposed ports.
|
||||
hs1-net:
|
||||
aliases: [hs1, rc1, rc.host]
|
||||
rc1-net:
|
||||
aliases: [hs1, rc1, rc.host]
|
||||
element-net:
|
||||
aliases: [hs1, rc1, rc.host, element]
|
||||
|
||||
# HomeServer 1 (synapse)
|
||||
hs1:
|
||||
image: matrixdotorg/synapse:v1.157.2
|
||||
profiles:
|
||||
- test
|
||||
- element
|
||||
entrypoint: |
|
||||
sh -c
|
||||
"update-ca-certificates &&
|
||||
mkdir -p /data/media_store &&
|
||||
chown -R 991:991 /data &&
|
||||
/start.py &
|
||||
until curl -sf http://localhost:8008/_matrix/client/versions; do
|
||||
echo '=====> Waiting for Synapse...';
|
||||
sleep 2;
|
||||
done;
|
||||
echo '';
|
||||
echo '=====> Running register_new_matrix_user...';
|
||||
register_new_matrix_user -u admin -p admin --admin http://localhost:8008 -c /data/homeserver.yaml | sed 's/^/=======> /';
|
||||
register_new_matrix_user -u alice -p alice --admin http://localhost:8008 -c /data/homeserver.yaml | sed 's/^/=======> /';
|
||||
register_new_matrix_user -u bob -p bob --admin http://localhost:8008 -c /data/homeserver.yaml | sed 's/^/=======> /';
|
||||
register_new_matrix_user -u cleiton -p cleiton --admin http://localhost:8008 -c /data/homeserver.yaml | sed 's/^/=======> /';
|
||||
echo '=====> Finished register_new_matrix_user.';
|
||||
wait"
|
||||
volumes:
|
||||
- ./docker-compose/hs1/homeserver.yaml:/data/homeserver.yaml
|
||||
- ./docker-compose/hs1/hs1.log.config:/data/hs1.log.config
|
||||
- ./docker-compose/hs1/hs1.signing.key:/data/hs1.signing.key
|
||||
- ./docker-compose/traefik/certs/ca:/usr/local/share/ca-certificates
|
||||
networks:
|
||||
- hs1-net
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
- 'traefik.http.routers.hs1.rule=Host(`hs1`)'
|
||||
- 'traefik.http.routers.hs1.entrypoints=websecure'
|
||||
- 'traefik.http.routers.hs1.tls=true'
|
||||
- 'traefik.http.services.hs1.loadbalancer.server.port=8008'
|
||||
|
||||
# Rocket.Chat rc1
|
||||
rc1:
|
||||
build:
|
||||
context: ${ROCKETCHAT_BUILD_CONTEXT:-./test/dist}
|
||||
dockerfile: ${ROCKETCHAT_DOCKERFILE:-../../../apps/meteor/.docker/Dockerfile.alpine}
|
||||
target: release-standard
|
||||
image: ${ROCKETCHAT_IMAGE:-rocketchat/rocket.chat:latest}
|
||||
profiles:
|
||||
- test
|
||||
- element
|
||||
environment:
|
||||
ROOT_URL: https://rc1
|
||||
PORT: 3000
|
||||
MONGO_URL: mongodb://mongo:27017/rc1?replicaSet=rs0
|
||||
NODE_EXTRA_CA_CERTS: /usr/local/share/ca-certificates/rootCA.pem
|
||||
LOG_LEVEL: debug
|
||||
ROCKETCHAT_LICENSE: ${ENTERPRISE_LICENSE_RC1}
|
||||
OVERWRITE_SETTING_Show_Setup_Wizard: completed
|
||||
OVERWRITE_SETTING_Federation_Service_Enabled: true
|
||||
OVERWRITE_SETTING_Federation_Service_Domain: rc1
|
||||
OVERWRITE_SETTING_Cloud_Workspace_Client_Id: temp_id
|
||||
OVERWRITE_SETTING_Cloud_Workspace_Client_Secret: temp_secret
|
||||
ADMIN_USERNAME: admin
|
||||
ADMIN_PASS: admin
|
||||
ADMIN_EMAIL: admin@admin.com
|
||||
TEST_MODE: api
|
||||
volumes:
|
||||
- ./docker-compose/traefik/certs/ca/rootCA.crt:/usr/local/share/ca-certificates/rootCA.pem
|
||||
networks:
|
||||
- rc1-net
|
||||
depends_on:
|
||||
- mongo
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
- 'traefik.http.routers.rc1.rule=Host(`rc1`)'
|
||||
- 'traefik.http.routers.rc1.entrypoints=websecure'
|
||||
- 'traefik.http.routers.rc1.tls=true'
|
||||
- 'traefik.http.services.rc1.loadbalancer.server.port=3000'
|
||||
# HTTPS Redirect
|
||||
- 'traefik.http.middlewares.rc1.redirectscheme.scheme=https'
|
||||
- 'traefik.http.routers.rc1-http.rule=Host(`rc1`)'
|
||||
- 'traefik.http.routers.rc1-http.middlewares=rc1'
|
||||
|
||||
mongo:
|
||||
image: mongo:${MONGODB_FEDERATION_VERSION:-8.0}
|
||||
profiles:
|
||||
- test
|
||||
- element
|
||||
restart: on-failure
|
||||
ports:
|
||||
- '27017:27017'
|
||||
entrypoint: |
|
||||
bash -c
|
||||
"mongod --replSet rs0 --bind_ip_all &
|
||||
sleep 2;
|
||||
until mongosh --eval \"db.adminCommand('ping')\"; do
|
||||
echo '=====> Waiting for Mongo...';
|
||||
sleep 1;
|
||||
done;
|
||||
echo '=====> Initiating ReplSet...';
|
||||
mongosh --eval \"rs.initiate({_id: 'rs0', members: [{ _id: 0, host: 'mongo:27017' }]})\";
|
||||
echo '=====> Initiating ReplSet done...';
|
||||
wait"
|
||||
networks:
|
||||
- rc1-net
|
||||
|
||||
element:
|
||||
image: vectorim/element-web
|
||||
profiles:
|
||||
- element
|
||||
# ports:
|
||||
# - "8080:80"
|
||||
volumes:
|
||||
- ./docker-compose/element/config.json:/app/config.json
|
||||
networks:
|
||||
- element-net
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
- 'traefik.http.routers.element.rule=Host(`element`)'
|
||||
- 'traefik.http.routers.element.entrypoints=websecure'
|
||||
- 'traefik.http.routers.element.tls=true'
|
||||
- 'traefik.http.services.element.loadbalancer.server.port=80'
|
||||
# HTTPS Redirect
|
||||
- 'traefik.http.middlewares.element.redirectscheme.scheme=https'
|
||||
- 'traefik.http.routers.element-http.rule=Host(`element`)'
|
||||
- 'traefik.http.routers.element-http.middlewares=element'
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"default_server_config": {
|
||||
"m.homeserver": {
|
||||
"base_url": "https://hs1",
|
||||
"server_name": "hs1"
|
||||
},
|
||||
"m.identity_server": {
|
||||
"base_url": "https://vector.im"
|
||||
}
|
||||
},
|
||||
"disable_password_rules": true,
|
||||
"disable_custom_urls": false,
|
||||
"disable_guests": false,
|
||||
"brand": "Element",
|
||||
"enable_presence_by_hs_url": {
|
||||
"https://hs1": false,
|
||||
"https://rc1": false
|
||||
}
|
||||
}
|
||||
154
ee/packages/federation-matrix/docker-compose/hs1/homeserver.yaml
Normal file
154
ee/packages/federation-matrix/docker-compose/hs1/homeserver.yaml
Normal file
@@ -0,0 +1,154 @@
|
||||
# Configuration file for Synapse.
|
||||
server_name: "hs1"
|
||||
pid_file: /data/hs1.pid
|
||||
|
||||
listeners:
|
||||
- port: 8008
|
||||
tls: false
|
||||
type: http
|
||||
x_forwarded: true
|
||||
resources:
|
||||
- names: [client, federation]
|
||||
|
||||
use_x_forwarded_for: true
|
||||
serve_server_wellknown: true
|
||||
|
||||
database:
|
||||
name: sqlite3
|
||||
args:
|
||||
database: /data/hs1.db
|
||||
|
||||
log_config: "/data/hs1.log.config"
|
||||
media_store_path: /data/media_store
|
||||
|
||||
registration_shared_secret: "3l5H.Y5urc5@gKwYMe2^abk@nf.U_M6iyMgP,j&OL6pcSGrUQE"
|
||||
report_stats: false
|
||||
macaroon_secret_key: "3II&OLx=,6RcC&E~iksWi:tUU4J.~reK:MdU&JAPhlaGIL*+IA"
|
||||
form_secret: "B2EuUMKfg&.BsU;#DZ9;E,baB^wc=lV--F_PwkRLZvk=LHk+U4"
|
||||
signing_key_path: "/data/hs1.signing.key"
|
||||
|
||||
trusted_key_servers: []
|
||||
# - server_name: "matrix.org"
|
||||
# accept_keys_insecurely: true
|
||||
|
||||
federation_client_minimum_tls_version: 1.2
|
||||
federation_verify_certificates: false
|
||||
suppress_key_server_warning: true
|
||||
|
||||
enable_registration: true
|
||||
enable_registration_without_verification: true
|
||||
allow_public_rooms_over_federation: true
|
||||
|
||||
password_config:
|
||||
enabled: true
|
||||
localdb_enabled: true
|
||||
policy:
|
||||
minimum_length: 1
|
||||
require_lowercase: false
|
||||
require_uppercase: false
|
||||
require_digit: false
|
||||
require_symbol: false
|
||||
|
||||
# Optionally, add your Docker network to the whitelist
|
||||
ip_range_whitelist:
|
||||
- '0.0.0.0/0'
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# Development rate limits (set very high)
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
# Messaging
|
||||
rc_message:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
# Registration
|
||||
rc_registration:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
# Registration token validity checks
|
||||
rc_registration_token_validity:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
# Room creation
|
||||
rc_room_creation:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
# Login (IP, account, and failed-attempt buckets)
|
||||
rc_login:
|
||||
address:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
account:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
failed_attempts:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
# Joins
|
||||
rc_joins:
|
||||
local:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
remote:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
# Limit recent joins per room
|
||||
rc_joins_per_room:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
# Invites
|
||||
rc_invites:
|
||||
per_room:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
per_user:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
# 3PID validation (email/phone)
|
||||
rc_3pid_validation:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
# To-device and key requests
|
||||
rc_to_device:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
rc_key_requests:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
# Presence / typing / receipts / read markers
|
||||
rc_presence:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
rc_typing:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
rc_read_receipts:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
rc_read_markers:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
# Room-admin redactions
|
||||
rc_admin_redaction:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
# Federation (uses *different* keys, not per_second/burst_count)
|
||||
rc_federation:
|
||||
window_size: 10000 # ms
|
||||
sleep_limit: 10000
|
||||
sleep_delay: 0 # ms
|
||||
reject_limit: 10000
|
||||
concurrent: 10000
|
||||
# vim:ft=yaml
|
||||
@@ -0,0 +1,39 @@
|
||||
version: 1
|
||||
|
||||
formatters:
|
||||
precise:
|
||||
|
||||
format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'
|
||||
|
||||
|
||||
handlers:
|
||||
|
||||
|
||||
console:
|
||||
class: logging.StreamHandler
|
||||
formatter: precise
|
||||
|
||||
loggers:
|
||||
# This is just here so we can leave `loggers` in the config regardless of whether
|
||||
# we configure other loggers below (avoid empty yaml dict error).
|
||||
_placeholder:
|
||||
level: "INFO"
|
||||
|
||||
|
||||
|
||||
synapse.storage.SQL:
|
||||
# beware: increasing this to DEBUG will make synapse log sensitive
|
||||
# information such as access tokens.
|
||||
level: INFO
|
||||
|
||||
|
||||
|
||||
|
||||
root:
|
||||
level: INFO
|
||||
|
||||
|
||||
handlers: [console]
|
||||
|
||||
|
||||
disable_existing_loggers: false
|
||||
@@ -0,0 +1 @@
|
||||
ed25519 a_HDhg WntaJ4JP5WbZZjDShjeuwqCybQ5huaZAiowji7tnIEw
|
||||
18
ee/packages/federation-matrix/docker-compose/traefik/certs/.gitignore
vendored
Normal file
18
ee/packages/federation-matrix/docker-compose/traefik/certs/.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
# Allow only the certificates needed for CI testing
|
||||
# Override the root .gitignore rule for *.pem
|
||||
|
||||
# Allow rc1 certificates
|
||||
!rc1.pem
|
||||
!rc1-key.pem
|
||||
|
||||
# Allow hs1 certificates
|
||||
!hs1.pem
|
||||
!hs1-key.pem
|
||||
|
||||
# Allow element certificates
|
||||
!element.pem
|
||||
!element-key.pem
|
||||
|
||||
# Keep ignoring all other .pem files
|
||||
*.pem
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFLDCCA5SgAwIBAgIRAMEZ3b7sw0tDnhAcUN+76PkwDQYJKoZIhvcNAQELBQAw
|
||||
ga0xHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTFBMD8GA1UECww4cm9j
|
||||
a2V0Y2hhdEBSb2NrZXRDaGF0cy1NYWNCb29rLVByby0yLmxvY2FsIChSb2NrZXQu
|
||||
Q2hhdCkxSDBGBgNVBAMMP21rY2VydCByb2NrZXRjaGF0QFJvY2tldENoYXRzLU1h
|
||||
Y0Jvb2stUHJvLTIubG9jYWwgKFJvY2tldC5DaGF0KTAeFw0yNDExMzAxMTQyMDVa
|
||||
Fw0zNDExMzAxMTQyMDVaMIGtMR4wHAYDVQQKExVta2NlcnQgZGV2ZWxvcG1lbnQg
|
||||
Q0ExQTA/BgNVBAsMOHJvY2tldGNoYXRAUm9ja2V0Q2hhdHMtTWFjQm9vay1Qcm8t
|
||||
Mi5sb2NhbCAoUm9ja2V0LkNoYXQpMUgwRgYDVQQDDD9ta2NlcnQgcm9ja2V0Y2hh
|
||||
dEBSb2NrZXRDaGF0cy1NYWNCb29rLVByby0yLmxvY2FsIChSb2NrZXQuQ2hhdCkw
|
||||
ggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQDUSzYDKSavpVKGEG0M2WQb
|
||||
LShnhwqx+TZRlncn85RNJNkVooovw0x4Q104Rmo3xOS2fwaKC5fgX1T+KNvTFFVz
|
||||
dyEhsGPYBSQLD27nQ1pgO5RDMsOC54dSoFLq6SbRZXpKBjiYGvB9sQ6mkSk+DWIX
|
||||
EpacbgPsmJma8pm5C03SZI8HSlUYhSKYdjF6ph/6gBeRMUu5emnx3GRYS5GMGYZJ
|
||||
XxUF2HbCYeGoubbc6UvLdtKyWmo5qL0gIVfoNTlJK/s61QzFDY9pA6ULoMBwocPh
|
||||
KW/1RFODSPYyjwVgckXGil3jcWHVpwLVdZXsOWu+2sbZpCfkXNGrasDm6NBjKJK+
|
||||
uxM8+2zz/7yZX+SOh0bVK0St8R1Khl+jLC/xwGA4tVYT04S2f+1OWT+dqfCWHNjq
|
||||
7GOcs1AQIvh8Yntx+1gwRIqcirGQCTtvzsF+zKWPmh1vip9fBkVL/6N4JK3XALiI
|
||||
c6DImZHKG/6Ezw3fCilTV/MTnXeRazRyNozigJoQd5sCAwEAAaNFMEMwDgYDVR0P
|
||||
AQH/BAQDAgIEMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFPMCCoABwDeC
|
||||
i5pWH8Fm1EKCREu1MA0GCSqGSIb3DQEBCwUAA4IBgQCzSvcM8hPFFT0bzd9VYAoF
|
||||
wKHDRJJfj3YgVY+ogfxiAvn8S/n2BBPHnPMLstGUTnNdd4Nr0QYot1H0H23QdS3m
|
||||
gHbodVmp3+Ylc51RepCOqjT4nL6nCYcXr4DrDbIExIT+YyLRlrGyChEwn0AfdvFO
|
||||
6+kfN6Jj0yT2uGTw0yz9x6Cnl0wJozhubcPEahc/lyhjxkYFA0YujNhS5ZPk33sH
|
||||
jVJC2k3vSLD/wS6vYq+4A2yUB4KlVLVLUxEakzK5fEoAkyh2wnFAKa8uVcwU2Fz5
|
||||
mEJv4KmW8SpBTQYXILCZFXLO1ON9Ge7yYFquLMAo5QVrnwVm5EFgN2+Lnw6n8h+M
|
||||
OhoPw4II46EEbMFN42aVV1jwIasIitO0eXG9XB+ZaPA64xcwOzRK2g65IhjPBrLi
|
||||
MiugUT/fILCBkogBwctcpfkm6+hbVM1xLkkcUOgX6+nGSg8KvpRPWqx7bn2uoa5E
|
||||
uT4+6tdAtAXFCdbIk8pMsfz3fFLjL5BBDtPfa+7mnyw=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,13 @@
|
||||
tls:
|
||||
certificates:
|
||||
# synapse1
|
||||
- certFile: "/etc/traefik/certs/hs1.pem"
|
||||
keyFile: "/etc/traefik/certs/hs1-key.pem"
|
||||
|
||||
# rocket.chat rc1 (container)
|
||||
- certFile: "/etc/traefik/certs/rc1.pem"
|
||||
keyFile: "/etc/traefik/certs/rc1-key.pem"
|
||||
|
||||
# element
|
||||
- certFile: "/etc/traefik/certs/element.pem"
|
||||
keyFile: "/etc/traefik/certs/element-key.pem"
|
||||
@@ -0,0 +1,20 @@
|
||||
entryPoints:
|
||||
web:
|
||||
address: ":80"
|
||||
websecure:
|
||||
address: ":443"
|
||||
|
||||
providers:
|
||||
docker:
|
||||
exposedByDefault: false
|
||||
file:
|
||||
filename: "/etc/traefik/dynamic_conf.yml"
|
||||
|
||||
api:
|
||||
dashboard: false
|
||||
insecure: false
|
||||
|
||||
# accessLog:
|
||||
# filePath: "/logs/access.log"
|
||||
# bufferingSize: 100
|
||||
# format: json
|
||||
63
ee/packages/federation-matrix/jest.config.federation.ts
Normal file
63
ee/packages/federation-matrix/jest.config.federation.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Jest configuration for federation integration tests.
|
||||
*
|
||||
* Extends the base server preset with federation-specific settings including
|
||||
* extended timeouts for distributed system operations, proper module transformation
|
||||
* for Matrix SDK dependencies, and global teardown for resource cleanup.
|
||||
*/
|
||||
import server from '@rocket.chat/jest-presets/server';
|
||||
import type { Config } from 'jest';
|
||||
|
||||
function qaseRunTitle(): string {
|
||||
const title = ['Federation E2E Tests'];
|
||||
|
||||
if (process.env.PR_NUMBER) {
|
||||
title.push(`PR ${process.env.PR_NUMBER}`);
|
||||
}
|
||||
|
||||
if (process.env.GITHUB_RUN_ID) {
|
||||
title.push(`Run ${process.env.GITHUB_RUN_ID}`);
|
||||
}
|
||||
|
||||
title.push(new Date().toISOString());
|
||||
|
||||
return title.join(' - ');
|
||||
}
|
||||
|
||||
export default {
|
||||
preset: server.preset,
|
||||
transformIgnorePatterns: [
|
||||
'<rootDir>/node_modules/@babel',
|
||||
'<rootDir>/node_modules/@jest',
|
||||
'/node_modules/(?!marked|@testing-library|matrix-js-sdk|@vector-im)/',
|
||||
],
|
||||
// Federation-specific configuration
|
||||
testMatch: ['<rootDir>/tests/end-to-end/**/*.spec.ts'],
|
||||
testTimeout: 30000, // 30 seconds timeout for federation tests
|
||||
forceExit: true, // Force Jest to exit after tests complete
|
||||
detectOpenHandles: true, // Detect open handles that prevent Jest from exiting
|
||||
globalTeardown: '<rootDir>/tests/teardown.ts',
|
||||
verbose: false,
|
||||
silent: false,
|
||||
reporters: [
|
||||
'default',
|
||||
...(process.env.QASE_TESTOPS_JEST_API_TOKEN
|
||||
? [
|
||||
[
|
||||
'jest-qase-reporter',
|
||||
{
|
||||
mode: 'testops',
|
||||
testops: {
|
||||
api: { token: process.env.QASE_TESTOPS_JEST_API_TOKEN },
|
||||
project: process.env.QASE_PROJECT || 'RC',
|
||||
run: {
|
||||
title: qaseRunTitle(),
|
||||
complete: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
] as [string, { [x: string]: unknown }],
|
||||
]
|
||||
: []),
|
||||
] as Config['reporters'],
|
||||
} satisfies Config;
|
||||
14
ee/packages/federation-matrix/jest.config.ts
Normal file
14
ee/packages/federation-matrix/jest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import server from '@rocket.chat/jest-presets/server';
|
||||
import type { Config } from 'jest';
|
||||
|
||||
export default {
|
||||
preset: server.preset,
|
||||
transformIgnorePatterns: [
|
||||
'<rootDir>/node_modules/@babel',
|
||||
'<rootDir>/node_modules/@jest',
|
||||
// sanitize-html vendors its own (ESM-only) htmlparser2 + friends under a nested node_modules
|
||||
'/node_modules/(?!marked|@testing-library/|(dom-serializer|domelementtype|domhandler|domutils|entities|htmlparser2)/|sanitize-html/node_modules/)',
|
||||
],
|
||||
// Exclude integration/e2e tests from unit test runs
|
||||
testPathIgnorePatterns: ['<rootDir>/tests/end-to-end/'],
|
||||
} satisfies Config;
|
||||
58
ee/packages/federation-matrix/package.json
Normal file
58
ee/packages/federation-matrix/package.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "@rocket.chat/federation-matrix",
|
||||
"version": "0.2.1",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"typings": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"/dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
||||
"dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"test": "jest",
|
||||
"test:integration": "./tests/scripts/run-integration-tests.sh",
|
||||
"test:federation": "jest --config jest.config.federation.ts",
|
||||
"testunit": "jest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rocket.chat/core-services": "workspace:^",
|
||||
"@rocket.chat/core-typings": "workspace:^",
|
||||
"@rocket.chat/emitter": "workspace:~",
|
||||
"@rocket.chat/federation-sdk": "0.7.0",
|
||||
"@rocket.chat/http-router": "workspace:^",
|
||||
"@rocket.chat/license": "workspace:^",
|
||||
"@rocket.chat/models": "workspace:^",
|
||||
"@rocket.chat/network-broker": "workspace:^",
|
||||
"@rocket.chat/rest-typings": "workspace:^",
|
||||
"emojibase": "^16.0.0",
|
||||
"emojibase-data": "^16.0.3",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"marked": "^16.1.2",
|
||||
"mem": "^8.1.1",
|
||||
"mongodb": "6.16.0",
|
||||
"pino": "10.3.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"sanitize-html": "~2.17.7",
|
||||
"tsyringe": "^4.10.0",
|
||||
"tweetnacl": "^1.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rocket.chat/ddp-client": "workspace:^",
|
||||
"@types/lodash.debounce": "^4.0.9",
|
||||
"@types/node": "~24.13.2",
|
||||
"@types/sanitize-html": "~2.16.1",
|
||||
"eslint": "~9.39.5",
|
||||
"jest": "~30.2.0",
|
||||
"jest-qase-reporter": "^2.4.0",
|
||||
"matrix-js-sdk": "^38.4.0",
|
||||
"pino-pretty": "13.1.3",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"volta": {
|
||||
"extends": "../../../package.json"
|
||||
}
|
||||
}
|
||||
1216
ee/packages/federation-matrix/src/FederationMatrix.ts
Normal file
1216
ee/packages/federation-matrix/src/FederationMatrix.ts
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user