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

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

91
ee/apps/Dockerfile Normal file
View 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"]

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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);
}
}

View 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');

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

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

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

View 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));

View 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);
})();

View 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"]
}

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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');

View 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);
})();

View 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"]
}

File diff suppressed because it is too large Load Diff

View 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[];
}
}

View File

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

View File

@@ -0,0 +1,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"
}
}

View 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));
}
}

View 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();
}
}

View 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;
}
}

View 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();
});
});
});

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

View 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);
}
}
}
}

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

View 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

View 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');

View 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();

View 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);
};

View 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();
};
}

View 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);
}
});

View File

@@ -0,0 +1,9 @@
export interface IPacket {
name: string;
id: string;
method: string;
msg: string;
version: string;
support: string[];
params: any[];
}

View 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"]
}

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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');

View 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 };

View 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);
})();

View 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"]
}

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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');

View 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);
})();

View 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"]
}

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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');

View 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);
})();

View 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"]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,90 @@
import { VirtruClient } from './VirtruClient';
const serverFetchMock = jest.fn();
jest.mock('@rocket.chat/server-fetch', () => ({ serverFetch: (...a: unknown[]) => serverFetchMock(...a) }));
const cfg = {
baseUrl: 'http://pdp',
clientId: 'cid',
clientSecret: 'sec',
oidcEndpoint: 'http://oidc',
defaultEntityKey: 'emailAddress' as const,
attributeNamespace: 'example.com',
};
const okJson = (body: unknown) => ({ ok: true, status: 200, json: async () => body, text: async () => '' });
beforeEach(() => serverFetchMock.mockReset());
describe('VirtruClient', () => {
it('caches the OIDC token across apiCalls (one token fetch serves many calls)', async () => {
serverFetchMock.mockResolvedValueOnce(okJson({ access_token: 'tok', expires_in: 3600 })).mockResolvedValue(okJson({}));
const c = new VirtruClient(cfg);
await c.apiCall('/x', {});
await c.apiCall('/y', {});
const tokenCalls = serverFetchMock.mock.calls.filter(([url]) => String(url).includes('openid-connect/token'));
expect(tokenCalls).toHaveLength(1);
});
it('updateConfig resets the token cache', async () => {
serverFetchMock.mockResolvedValue(okJson({ access_token: 'tok', expires_in: 3600 }));
const c = new VirtruClient(cfg);
await c.apiCall('/x', {});
c.updateConfig({ ...cfg, clientSecret: 'new' });
await c.apiCall('/x', {});
const tokenCalls = serverFetchMock.mock.calls.filter(([url]) => String(url).includes('openid-connect/token'));
expect(tokenCalls).toHaveLength(2);
});
it('isAvailable true only when /healthz status SERVING', async () => {
const c = new VirtruClient(cfg);
serverFetchMock.mockResolvedValueOnce(okJson({ status: 'SERVING' }));
expect(await c.isAvailable()).toBe(true);
serverFetchMock.mockResolvedValueOnce(okJson({ status: 'NOT_SERVING' }));
expect(await c.isAvailable()).toBe(false);
serverFetchMock.mockRejectedValueOnce(new Error('down'));
expect(await c.isAvailable()).toBe(false);
});
it('apiCall sends Bearer token', async () => {
serverFetchMock.mockResolvedValueOnce(okJson({ access_token: 'tok', expires_in: 3600 })).mockResolvedValueOnce(okJson({}));
const c = new VirtruClient(cfg);
await c.apiCall('/x', {});
const apiCallArgs = serverFetchMock.mock.calls.find(([url]) => String(url) === 'http://pdp/x');
expect((apiCallArgs?.[1] as any).headers.Authorization).toBe('Bearer tok');
});
it('getClientTokenForHealthCheck resets the cache then returns a freshly fetched token', async () => {
serverFetchMock
.mockResolvedValueOnce(okJson({ access_token: 'tok1', expires_in: 3600 }))
.mockResolvedValueOnce(okJson({}))
.mockResolvedValueOnce(okJson({ access_token: 'tok2', expires_in: 3600 }));
const c = new VirtruClient(cfg);
await c.apiCall('/x', {});
const token = await c.getClientTokenForHealthCheck();
expect(token).toBe('tok2');
const tokenCalls = serverFetchMock.mock.calls.filter(([url]) => String(url).includes('openid-connect/token'));
expect(tokenCalls).toHaveLength(2);
});
it('getConfig exposes only baseUrl, defaultEntityKey, attributeNamespace and clientId', () => {
const c = new VirtruClient(cfg);
const pub = c.getConfig();
expect(pub).toEqual({
baseUrl: cfg.baseUrl,
defaultEntityKey: cfg.defaultEntityKey,
attributeNamespace: cfg.attributeNamespace,
clientId: cfg.clientId,
});
expect(pub).not.toHaveProperty('clientSecret');
expect(pub).not.toHaveProperty('oidcEndpoint');
});
it('apiCall throws on non-ok response', async () => {
serverFetchMock
.mockResolvedValueOnce(okJson({ access_token: 'tok', expires_in: 3600 }))
.mockResolvedValueOnce({ ok: false, status: 500, text: async () => 'boom' });
const c = new VirtruClient(cfg);
await expect(c.apiCall('/x', {})).rejects.toThrow('Virtru PDP call failed');
});
});

View File

@@ -0,0 +1,117 @@
import { serverFetch } from '@rocket.chat/server-fetch';
import { logger } from '../../logger';
import type { IVirtruPDPConfig, ITokenCache } from '../../pdp/types';
const virtruClientLogger = logger.section('VirtruClient');
export const HEALTH_CHECK_TIMEOUT = 5000;
const REQUEST_TIMEOUT = 10000;
type PublicVirtruConfig = Pick<IVirtruPDPConfig, 'baseUrl' | 'defaultEntityKey' | 'attributeNamespace' | 'clientId'>;
export class VirtruClient {
private tokenCache: ITokenCache | null = null;
private config: IVirtruPDPConfig;
constructor(config: IVirtruPDPConfig) {
this.config = config;
}
updateConfig(config: IVirtruPDPConfig): void {
this.config = config;
this.tokenCache = null;
}
getConfig(): PublicVirtruConfig {
const { baseUrl, defaultEntityKey, attributeNamespace, clientId } = this.config;
return { baseUrl, defaultEntityKey, attributeNamespace, clientId };
}
async isAvailable(): Promise<boolean> {
try {
const response = await serverFetch(`${this.config.baseUrl}/healthz`, {
method: 'GET',
timeout: HEALTH_CHECK_TIMEOUT,
// SECURITY: This can only be configured by users with enough privileges. It's ok to disable this check here.
ignoreSsrfValidation: true,
});
if (!response.ok) {
throw new Error('PDP Health check failed');
}
const data = (await response.json()) as { status?: string };
virtruClientLogger.info({ msg: 'Virtru PDP health check response', data });
return data.status === 'SERVING';
} catch (err) {
virtruClientLogger.warn({ msg: 'Virtru PDP is not reachable', err });
return false;
}
}
private async getClientToken(): Promise<string> {
if (this.tokenCache && Date.now() < this.tokenCache.expiresAt) {
return this.tokenCache.accessToken;
}
const response = await serverFetch(`${this.config.oidcEndpoint}/protocol/openid-connect/token`, {
method: 'POST',
timeout: REQUEST_TIMEOUT,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
}),
ignoreSsrfValidation: true,
});
if (!response.ok) {
throw new Error(`Failed to obtain client token: ${response.status} ${response.statusText}`);
}
const data = (await response.json()) as { access_token: string; expires_in?: number };
const expiresIn = data.expires_in ?? 300;
this.tokenCache = {
accessToken: data.access_token,
expiresAt: Date.now() + (expiresIn - 30) * 1000,
};
return data.access_token;
}
async getClientTokenForHealthCheck(): Promise<string> {
this.tokenCache = null;
return this.getClientToken();
}
async apiCall<T>(endpoint: string, body: unknown): Promise<T> {
const token = await this.getClientToken();
virtruClientLogger.debug({ msg: 'Virtru PDP API call request', endpoint, body });
const response = await serverFetch(`${this.config.baseUrl}${endpoint}`, {
method: 'POST',
timeout: REQUEST_TIMEOUT,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(body),
ignoreSsrfValidation: true,
});
if (!response.ok) {
const text = await response.text().catch(() => '');
virtruClientLogger.error({ msg: 'Virtru PDP API call failed', endpoint, status: response.status, response: text });
throw new Error('Virtru PDP call failed');
}
const data = (await response.json()) as T;
virtruClientLogger.debug({ msg: 'Virtru PDP API call response', endpoint, response: data });
return data;
}
}

View File

@@ -0,0 +1,50 @@
import { buildEntityIdentifier, getUserEntityKey, buildAttributeFqns, parseAttributeFqns } from './identity';
describe('virtru/identity', () => {
it('buildEntityIdentifier maps emailAddress vs oidcIdentifier', () => {
expect(buildEntityIdentifier('emailAddress', 'a@b.com')).toEqual({ emailAddress: 'a@b.com' });
expect(buildEntityIdentifier('oidcIdentifier', 'bob')).toEqual({ id: 'bob' });
});
it('getUserEntityKey resolves per defaultEntityKey', () => {
expect(getUserEntityKey('emailAddress', { _id: 'u', emails: [{ address: 'a@b.com', verified: true }], username: 'bob' })).toBe(
'a@b.com',
);
expect(getUserEntityKey('oidcIdentifier', { _id: 'u', emails: [], username: 'bob' })).toBe('bob');
expect(getUserEntityKey('emailAddress', { _id: 'u', username: 'bob' })).toBeUndefined();
});
it('getUserEntityKey never resolves to an unverified email address', () => {
expect(
getUserEntityKey('emailAddress', { _id: 'u', emails: [{ address: 'ceo@company.com', verified: false }], username: 'attacker' }),
).toBeUndefined();
expect(getUserEntityKey('emailAddress', { _id: 'u', emails: [{ address: 'ceo@company.com' }], username: 'attacker' })).toBeUndefined();
expect(
getUserEntityKey('emailAddress', {
_id: 'u',
emails: [
{ address: 'ceo@company.com', verified: false },
{ address: 'real@attacker.com', verified: true },
],
username: 'attacker',
}),
).toBe('real@attacker.com');
});
it('buildAttributeFqns round-trips with parseAttributeFqns', () => {
const attrs = [{ key: 'clearance', values: ['secret', 'topsecret'] }];
const fqns = buildAttributeFqns('example.com', attrs);
expect(fqns).toEqual(['https://example.com/attr/clearance/value/secret', 'https://example.com/attr/clearance/value/topsecret']);
expect(parseAttributeFqns(fqns).attributes).toEqual([{ key: 'clearance', values: ['secret', 'topsecret'] }]);
});
it('buildAttributeFqns throws when namespace empty', () => {
expect(() => buildAttributeFqns('', [{ key: 'k', values: ['v'] }])).toThrow();
});
it('parseAttributeFqns returns malformed FQNs alongside parsed attributes', () => {
const { attributes, malformed } = parseAttributeFqns(['https://example.com/attr/k/value/v', 'not-a-fqn', 'https://example.com/broken']);
expect(malformed).toEqual(['not-a-fqn', 'https://example.com/broken']);
expect(attributes).toEqual([{ key: 'k', values: ['v'] }]);
});
});

View File

@@ -0,0 +1,48 @@
import type { IAbacAttributeDefinition, IUser } from '@rocket.chat/core-typings';
import type { IEntityIdentifier } from '../../pdp/types';
export type EntityKeyType = 'emailAddress' | 'oidcIdentifier';
export function buildEntityIdentifier(defaultEntityKey: EntityKeyType, entityKey: string): IEntityIdentifier {
if (defaultEntityKey === 'emailAddress') {
return { emailAddress: entityKey };
}
return { id: entityKey };
}
export function getUserEntityKey(defaultEntityKey: EntityKeyType, user: Pick<IUser, '_id' | 'emails' | 'username'>): string | undefined {
switch (defaultEntityKey) {
case 'emailAddress':
return user.emails?.find((email) => email.verified)?.address;
case 'oidcIdentifier':
return user.username;
}
}
export function buildAttributeFqns(attributeNamespace: string, attributes: IAbacAttributeDefinition[]): string[] {
if (!attributeNamespace) {
throw new Error('Attribute namespace is not configured for VirtruPDP');
}
return attributes.flatMap((attr) => attr.values.map((value) => `https://${attributeNamespace}/attr/${attr.key}/value/${value}`));
}
const FQN_RE = /^https:\/\/[^/]+\/attr\/([^/]+)\/value\/(.+)$/;
export function parseAttributeFqns(fqns: string[]): { attributes: IAbacAttributeDefinition[]; malformed: string[] } {
const grouped = new Map<string, Set<string>>();
const malformed: string[] = [];
for (const fqn of fqns) {
const m = FQN_RE.exec(fqn);
if (!m) {
malformed.push(fqn);
continue;
}
const [, key, value] = m;
const bucket = grouped.get(key) ?? new Set<string>();
bucket.add(value);
grouped.set(key, bucket);
}
const attributes = [...grouped.entries()].map(([key, values]) => ({ key, values: [...values] }));
return { attributes, malformed };
}

View File

@@ -0,0 +1,139 @@
import { MeteorError, isMeteorError } from '@rocket.chat/core-services';
export enum AbacErrorCode {
InvalidAttributeValues = 'error-invalid-attribute-values',
InvalidAttributeKey = 'error-invalid-attribute-key',
AttributeNotFound = 'error-attribute-not-found',
AttributeInUse = 'error-attribute-in-use',
DuplicateAttributeKey = 'error-duplicate-attribute-key',
AttributeDefinitionNotFound = 'error-attribute-definition-not-found',
RoomNotFound = 'error-room-not-found',
CannotConvertDefaultRoomToAbac = 'error-cannot-convert-default-room-to-abac',
AbacUnsupportedObjectType = 'error-abac-unsupported-object-type',
AbacUnsupportedOperation = 'error-abac-unsupported-operation',
OnlyCompliantCanBeAddedToRoom = 'error-only-compliant-users-can-be-added-to-abac-rooms',
PdpUnavailable = 'error-pdp-unavailable',
AttributeStoreExternal = 'error-abac-attribute-store-external',
EntityResolutionFailed = 'error-virtru-entity-resolution-failed',
NotAuthorizedToModifyRoom = 'error-abac-not-authorized-to-modify-room',
}
export class AbacError extends Error {
public readonly code: AbacErrorCode;
public readonly details?: unknown;
constructor(code: AbacErrorCode, details?: unknown) {
super(code);
this.code = code;
this.details = details;
Object.setPrototypeOf(this, new.target.prototype);
}
}
export class AbacInvalidAttributeValuesError extends AbacError {
constructor(details?: unknown) {
super(AbacErrorCode.InvalidAttributeValues, details);
}
}
export class AbacInvalidAttributeKeyError extends AbacError {
constructor(details?: unknown) {
super(AbacErrorCode.InvalidAttributeKey, details);
}
}
export class AbacAttributeNotFoundError extends AbacError {
constructor(details?: unknown) {
super(AbacErrorCode.AttributeNotFound, details);
}
}
export class AbacAttributeInUseError extends AbacError {
constructor(details?: unknown) {
super(AbacErrorCode.AttributeInUse, details);
}
}
export class AbacDuplicateAttributeKeyError extends AbacError {
constructor(details?: unknown) {
super(AbacErrorCode.DuplicateAttributeKey, details);
}
}
export class AbacAttributeDefinitionNotFoundError extends AbacError {
constructor(details?: unknown) {
super(AbacErrorCode.AttributeDefinitionNotFound, details);
}
}
export class AbacRoomNotFoundError extends AbacError {
constructor(details?: unknown) {
super(AbacErrorCode.RoomNotFound, details);
}
}
export class AbacCannotConvertDefaultRoomToAbacError extends AbacError {
constructor(details?: unknown) {
super(AbacErrorCode.CannotConvertDefaultRoomToAbac, details);
}
}
export class AbacUnsupportedObjectTypeError extends AbacError {
constructor(details?: unknown) {
super(AbacErrorCode.AbacUnsupportedObjectType, details);
}
}
export class AbacUnsupportedOperationError extends AbacError {
constructor(details?: unknown) {
super(AbacErrorCode.AbacUnsupportedOperation, details);
}
}
export class OnlyCompliantCanBeAddedToRoomError extends AbacError {
constructor(details?: unknown) {
super(AbacErrorCode.OnlyCompliantCanBeAddedToRoom, details);
}
}
export class PdpUnavailableError extends AbacError {
constructor(details?: unknown) {
super(AbacErrorCode.PdpUnavailable, details);
}
}
export class AbacAttributeStoreExternalError extends AbacError {
constructor(details?: unknown) {
super(AbacErrorCode.AttributeStoreExternal, details);
}
}
export class AbacEntityResolutionFailedError extends AbacError {
constructor(details?: unknown) {
super(AbacErrorCode.EntityResolutionFailed, details);
}
}
export class AbacNotAuthorizedToModifyRoomError extends AbacError {
constructor(details?: unknown) {
super(AbacErrorCode.NotAuthorizedToModifyRoom, details);
}
}
export class PdpHealthCheckError extends MeteorError {
constructor(errorCode: string) {
super(errorCode, errorCode);
this.message = errorCode;
Object.setPrototypeOf(this, new.target.prototype);
}
}
export const getPdpHealthErrorCode = (err: unknown): string => {
if (isMeteorError(err) && err.reason?.startsWith('ABAC_PDP_Health_')) {
return err.reason;
}
return 'ABAC_PDP_Health_Not_OK';
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,54 @@
import { LocalAttributeStore } from './LocalAttributeStore';
const ensureMock = jest.fn();
jest.mock('../helper', () => ({ ensureAttributeDefinitionsExist: (...a: unknown[]) => ensureMock(...a) }));
const findPaginated = jest.fn();
jest.mock('@rocket.chat/models', () => ({ AbacAttributes: { findPaginated: (...a: unknown[]) => findPaginated(...a) } }));
const actor = { _id: 'u', username: 'bob', name: 'Bob' };
beforeEach(() => {
ensureMock.mockReset();
findPaginated.mockReset();
});
describe('LocalAttributeStore', () => {
it('validateAssignable delegates to ensureAttributeDefinitionsExist', async () => {
const attrs = [{ key: 'k', values: ['v'] }];
await new LocalAttributeStore().validateAssignable(attrs, actor);
expect(ensureMock).toHaveBeenCalledWith(attrs);
});
it('scopeRoomsPage is identity pass-through (no flag)', async () => {
const rooms = [{ _id: 'r1', abacAttributes: [{ key: 'k', values: ['v'] }] }];
const out = await new LocalAttributeStore().scopeRoomsPage(rooms, actor);
expect(out).toEqual(rooms);
expect(out[0]).not.toHaveProperty('abacAttributesRedacted');
});
it('assertCanModifyRoom is a no-op', async () => {
await expect(new LocalAttributeStore().assertCanModifyRoom({ _id: 'r', abacAttributes: [] }, actor)).resolves.toBeUndefined();
});
it('entitlementsOf returns the everything sentinel (empty map)', async () => {
const e = await new LocalAttributeStore().entitlementsOf(actor);
expect(e.size).toBe(0);
});
it('list queries AbacAttributes paginated (no filters)', async () => {
const docs = [{ _id: 'id1', key: 'k', values: ['v'] }];
findPaginated.mockReturnValue({ cursor: { toArray: async () => docs }, totalCount: Promise.resolve(1) });
const r = await new LocalAttributeStore().list(actor, { offset: 0, count: 25 });
expect(findPaginated).toHaveBeenCalledWith({}, { projection: { key: 1, values: 1 }, skip: 0, limit: 25 });
expect(r).toEqual({ attributes: docs, offset: 0, count: 1, total: 1 });
});
it('list builds $or query when key and values filters provided', async () => {
findPaginated.mockReturnValue({ cursor: { toArray: async () => [] }, totalCount: Promise.resolve(0) });
await new LocalAttributeStore().list(actor, { key: 'foo', values: 'bar', offset: 5, count: 10 });
expect(findPaginated).toHaveBeenCalledWith(
{ $or: [{ key: /foo/i }, { values: /bar/i }] },
{ projection: { key: 1, values: 1 }, skip: 5, limit: 10 },
);
});
});

View File

@@ -0,0 +1,58 @@
import type { AbacActor } from '@rocket.chat/core-services';
import type { IAbacAttributeDefinition, IRoom, IRoomAbacRedaction } from '@rocket.chat/core-typings';
import { AbacAttributes } from '@rocket.chat/models';
import { escapeRegExp } from '@rocket.chat/tools';
import type { Document } from 'mongodb';
import { ensureAttributeDefinitionsExist } from '../helper';
import type { AttributeEntitlements, IAttributeStore, ListAttributesOptions, ListAttributesResult } from './types';
export class LocalAttributeStore implements IAttributeStore {
async list(_actor: AbacActor | undefined, opts?: ListAttributesOptions): Promise<ListAttributesResult> {
const offset = opts?.offset ?? 0;
const limit = opts?.count ?? 25;
const clauses: Document[] = [];
if (opts?.key) {
clauses.push({ key: new RegExp(escapeRegExp(opts.key), 'i') });
}
if (opts?.values?.length) {
clauses.push({ values: new RegExp(escapeRegExp(opts.values), 'i') });
}
const { cursor, totalCount } = AbacAttributes.findPaginated(
{ ...(clauses.length && { $or: clauses }) },
{
projection: { key: 1, values: 1 },
skip: offset,
limit,
},
);
const attributes = await cursor.toArray();
return {
attributes,
offset,
count: attributes.length,
total: await totalCount,
};
}
async validateAssignable(attrs: IAbacAttributeDefinition[], _actor: AbacActor): Promise<void> {
await ensureAttributeDefinitionsExist(attrs);
}
async entitlementsOf(_actor: AbacActor): Promise<AttributeEntitlements> {
return new Map();
}
async scopeRoomsPage<T extends Pick<IRoom, '_id' | 'abacAttributes'>>(
rooms: T[],
_actor: AbacActor,
): Promise<Array<T & IRoomAbacRedaction>> {
return rooms;
}
async assertCanModifyRoom(_room: Pick<IRoom, '_id' | 'abacAttributes'>, _actor: AbacActor): Promise<void> {
// nop
}
}

View File

@@ -0,0 +1,301 @@
import { VirtruAttributeStore } from './VirtruAttributeStore';
const usersFindOneById = jest.fn();
jest.mock('@rocket.chat/models', () => ({ Users: { findOneById: (...a: unknown[]) => usersFindOneById(...a) } }));
const cfg = {
baseUrl: 'http://pdp',
clientId: 'c',
clientSecret: 's',
oidcEndpoint: 'http://o',
defaultEntityKey: 'emailAddress' as const,
attributeNamespace: 'example.com',
};
const actor = { _id: 'u1', username: 'bob', name: 'Bob' };
const mkClient = (over: Partial<Record<'isAvailable' | 'apiCall' | 'getConfig', jest.Mock>> = {}) =>
({
isAvailable: over.isAvailable ?? jest.fn().mockResolvedValue(true),
apiCall: over.apiCall ?? jest.fn(),
getConfig: over.getConfig ?? jest.fn().mockReturnValue(cfg),
}) as any;
beforeEach(() =>
usersFindOneById.mockReset().mockResolvedValue({ _id: 'u1', emails: [{ address: 'bob@x.com', verified: true }], username: 'bob' }),
);
describe('VirtruAttributeStore.entitlementsOf / list', () => {
it('parses FQN map keys into grouped {key,values}', async () => {
const apiCall = jest.fn().mockResolvedValue({
entitlements: [
{
actionsPerAttributeValueFqn: {
'https://example.com/attr/clearance/value/secret': {},
'https://example.com/attr/clearance/value/topsecret': {},
'https://example.com/attr/team/value/blue': {},
},
},
],
});
const store = new VirtruAttributeStore(mkClient({ apiCall }));
const r = await store.list(actor);
expect(r.attributes).toEqual(
expect.arrayContaining([
{ _id: 'clearance', key: 'clearance', values: expect.arrayContaining(['secret', 'topsecret']) },
{ _id: 'team', key: 'team', values: ['blue'] },
]),
);
expect(r).toMatchObject({ offset: 0, total: 2 });
});
it('malformed FQNs from PDP are skipped, valid ones returned', async () => {
const apiCall = jest.fn().mockResolvedValue({
entitlements: [
{
actionsPerAttributeValueFqn: {
'https://example.com/attr/clearance/value/secret': {},
'not-a-fqn': {},
},
},
],
});
const store = new VirtruAttributeStore(mkClient({ apiCall }));
const r = await store.list(actor);
expect(r.attributes).toEqual([{ _id: 'clearance', key: 'clearance', values: ['secret'] }]);
});
it('isAvailable() false => throws PdpUnavailable, no GetEntitlements', async () => {
const apiCall = jest.fn();
const store = new VirtruAttributeStore(mkClient({ isAvailable: jest.fn().mockResolvedValue(false), apiCall }));
await expect(store.list(actor)).rejects.toMatchObject({ code: 'error-pdp-unavailable' });
expect(apiCall).not.toHaveBeenCalled();
});
it('N concurrent calls for same entity before resolve => exactly one round-trip', async () => {
let resolve!: (v: unknown) => void;
const apiCall = jest.fn().mockReturnValue(
new Promise((r) => {
resolve = r;
}),
);
const store = new VirtruAttributeStore(mkClient({ apiCall }));
const p = Promise.all([store.entitlementsOf(actor), store.entitlementsOf(actor), store.entitlementsOf(actor)]);
resolve({ entitlements: [{ actionsPerAttributeValueFqn: { 'https://example.com/attr/k/value/v': {} } }] });
await p;
expect(apiCall).toHaveBeenCalledTimes(1);
});
it('cache keyed by resolved entity id (different entity => separate fetch)', async () => {
const apiCall = jest.fn().mockResolvedValue({ entitlements: [{ actionsPerAttributeValueFqn: {} }] });
const store = new VirtruAttributeStore(mkClient({ apiCall }));
await store.entitlementsOf(actor);
usersFindOneById.mockResolvedValue({ _id: 'u2', emails: [{ address: 'alice@x.com', verified: true }], username: 'alice' });
await store.entitlementsOf({ _id: 'u2', username: 'alice', name: 'A' });
expect(apiCall).toHaveBeenCalledTimes(2);
});
it('validateAssignable rejects an unpossessed value', async () => {
const apiCall = jest
.fn()
.mockResolvedValue({ entitlements: [{ actionsPerAttributeValueFqn: { 'https://example.com/attr/clearance/value/secret': {} } }] });
const store = new VirtruAttributeStore(mkClient({ apiCall }));
await expect(store.validateAssignable([{ key: 'clearance', values: ['secret'] }], actor)).resolves.toBeUndefined();
await expect(store.validateAssignable([{ key: 'clearance', values: ['topsecret'] }], actor)).rejects.toMatchObject({
code: 'error-invalid-attribute-values',
});
expect(apiCall).toHaveBeenCalledTimes(1);
});
it('validateAssignable rejects (fail-closed) with PdpUnavailable when Virtru is unreachable', async () => {
const apiCall = jest.fn();
const store = new VirtruAttributeStore(mkClient({ isAvailable: jest.fn().mockResolvedValue(false), apiCall }));
await expect(store.validateAssignable([{ key: 'clearance', values: ['secret'] }], actor)).rejects.toMatchObject({
code: 'error-pdp-unavailable',
});
expect(apiCall).not.toHaveBeenCalled();
});
it('validateAssignable propagates (fail-closed, never silently assigns) when the GetEntitlements call fails', async () => {
const apiCall = jest.fn().mockRejectedValue(new Error('entitlements down'));
const store = new VirtruAttributeStore(mkClient({ apiCall }));
await expect(store.validateAssignable([{ key: 'clearance', values: ['secret'] }], actor)).rejects.toThrow('entitlements down');
});
it('list propagates when the GetEntitlements call fails', async () => {
const apiCall = jest.fn().mockRejectedValue(new Error('entitlements down'));
const store = new VirtruAttributeStore(mkClient({ apiCall }));
await expect(store.list(actor)).rejects.toThrow('entitlements down');
});
it('failed fetch is NOT cached: second call after PDP recovery succeeds', async () => {
const isAvailable = jest.fn().mockResolvedValue(false);
const apiCall = jest.fn().mockResolvedValue({
entitlements: [{ actionsPerAttributeValueFqn: { 'https://example.com/attr/clearance/value/secret': {} } }],
});
const store = new VirtruAttributeStore(mkClient({ isAvailable, apiCall }));
await expect(store.entitlementsOf(actor)).rejects.toMatchObject({ code: 'error-pdp-unavailable' });
isAvailable.mockResolvedValue(true);
const result = await store.entitlementsOf(actor);
expect(result.get('clearance')).toEqual(new Set(['secret']));
expect(apiCall).toHaveBeenCalledTimes(1);
});
it('cached entry is auto-evicted after TTL elapses', async () => {
jest.useFakeTimers();
try {
const apiCall = jest.fn().mockResolvedValue({
entitlements: [{ actionsPerAttributeValueFqn: { 'https://example.com/attr/clearance/value/secret': {} } }],
});
const store = new VirtruAttributeStore(mkClient({ apiCall }));
await store.entitlementsOf(actor);
expect(apiCall).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(15_001);
await store.entitlementsOf(actor);
expect(apiCall).toHaveBeenCalledTimes(2);
} finally {
jest.useRealTimers();
}
});
it("failing fetch for one entity does NOT evict another entity's cached entitlements", async () => {
const apiCall = jest.fn();
apiCall.mockResolvedValueOnce({
entitlements: [{ actionsPerAttributeValueFqn: { 'https://example.com/attr/clearance/value/secret': {} } }],
});
const store = new VirtruAttributeStore(mkClient({ apiCall }));
await store.entitlementsOf(actor);
expect(apiCall).toHaveBeenCalledTimes(1);
apiCall.mockRejectedValueOnce(new Error('pdp blip'));
usersFindOneById.mockResolvedValue({ _id: 'u2', emails: [{ address: 'alice@x.com', verified: true }], username: 'alice' });
await expect(store.entitlementsOf({ _id: 'u2', username: 'alice', name: 'A' })).rejects.toThrow('pdp blip');
expect(apiCall).toHaveBeenCalledTimes(2);
usersFindOneById.mockResolvedValue({ _id: 'u1', emails: [{ address: 'bob@x.com', verified: true }], username: 'bob' });
const result = await store.entitlementsOf(actor);
expect(result.get('clearance')).toEqual(new Set(['secret']));
expect(apiCall).toHaveBeenCalledTimes(2);
});
it('onStoreSelected() forces the next entitlementsOf to re-hit the PDP', async () => {
const apiCall = jest.fn().mockResolvedValue({
entitlements: [{ actionsPerAttributeValueFqn: { 'https://example.com/attr/clearance/value/secret': {} } }],
});
const store = new VirtruAttributeStore(mkClient({ apiCall }));
await store.entitlementsOf(actor);
await store.entitlementsOf(actor);
expect(apiCall).toHaveBeenCalledTimes(1);
store.onStoreSelected();
await store.entitlementsOf(actor);
expect(apiCall).toHaveBeenCalledTimes(2);
});
it('unresolvable entity => entity-resolution error', async () => {
usersFindOneById.mockResolvedValue({ _id: 'u1', emails: [], username: 'bob' });
const store = new VirtruAttributeStore(mkClient());
await expect(store.list(actor)).rejects.toMatchObject({ code: 'error-virtru-entity-resolution-failed' });
});
});
describe('VirtruAttributeStore.scopeRoomsPage', () => {
const rooms = [
{ _id: 'rPermit', abacAttributes: [{ key: 'clearance', values: ['secret'] }] },
{ _id: 'rDeny', abacAttributes: [{ key: 'clearance', values: ['topsecret'] }] },
];
it('PERMIT unchanged, DENY redacted+flagged, ONE round-trip, order kept', async () => {
const apiCall = jest.fn().mockResolvedValue({
decisionResponses: [
{
resourceDecisions: [
{ ephemeralResourceId: 'rPermit', decision: 'DECISION_PERMIT' },
{ ephemeralResourceId: 'rDeny', decision: 'DECISION_DENY' },
],
},
],
});
const store = new VirtruAttributeStore(mkClient({ apiCall }));
const out = await store.scopeRoomsPage(rooms, actor);
expect(out.map((r) => r._id)).toEqual(['rPermit', 'rDeny']);
expect(out[0].abacAttributes).toEqual([{ key: 'clearance', values: ['secret'] }]);
expect(out[0]).not.toHaveProperty('abacAttributesRedacted');
expect(out[1].abacAttributes).toEqual([]);
expect(out[1].abacAttributesRedacted).toBe(true);
expect(apiCall).toHaveBeenCalledTimes(1);
});
it('isAvailable false => every room redacted, no throw, no apiCall', async () => {
const apiCall = jest.fn();
const store = new VirtruAttributeStore(mkClient({ isAvailable: jest.fn().mockResolvedValue(false), apiCall }));
const out = await store.scopeRoomsPage(rooms, actor);
expect(out[0].abacAttributesRedacted).toBe(true);
expect(out[1].abacAttributesRedacted).toBe(true);
expect(apiCall).not.toHaveBeenCalled();
});
it('decision call throws => fail-closed redact all, no throw', async () => {
const apiCall = jest.fn().mockRejectedValue(new Error('boom'));
const store = new VirtruAttributeStore(mkClient({ apiCall }));
const out = await store.scopeRoomsPage(rooms, actor);
expect(out[0].abacAttributesRedacted).toBe(true);
expect(out[1].abacAttributesRedacted).toBe(true);
});
});
describe('VirtruAttributeStore.assertCanModifyRoom', () => {
it("resolves when PDP PERMITs the room's current attributes", async () => {
const apiCall = jest
.fn()
.mockResolvedValue({ decisionResponses: [{ resourceDecisions: [{ ephemeralResourceId: 'r', decision: 'DECISION_PERMIT' }] }] });
await expect(
new VirtruAttributeStore(mkClient({ apiCall })).assertCanModifyRoom(
{ _id: 'r', abacAttributes: [{ key: 'k', values: ['v'] }] },
actor,
),
).resolves.toBeUndefined();
});
it('rejects with not-authorized when PDP DENYs', async () => {
const apiCall = jest
.fn()
.mockResolvedValue({ decisionResponses: [{ resourceDecisions: [{ ephemeralResourceId: 'r', decision: 'DECISION_DENY' }] }] });
await expect(
new VirtruAttributeStore(mkClient({ apiCall })).assertCanModifyRoom(
{ _id: 'r', abacAttributes: [{ key: 'k', values: ['v'] }] },
actor,
),
).rejects.toMatchObject({ code: 'error-abac-not-authorized-to-modify-room' });
});
it('rejects with PdpUnavailable when the decision call itself fails', async () => {
const apiCall = jest.fn().mockRejectedValue(new Error('network down'));
await expect(
new VirtruAttributeStore(mkClient({ apiCall })).assertCanModifyRoom(
{ _id: 'r', abacAttributes: [{ key: 'k', values: ['v'] }] },
actor,
),
).rejects.toMatchObject({ code: 'error-pdp-unavailable' });
});
it('resolves when the room has no current attributes', async () => {
await expect(
new VirtruAttributeStore(mkClient()).assertCanModifyRoom({ _id: 'r', abacAttributes: [] }, actor),
).resolves.toBeUndefined();
});
it('rejects (fail-closed) when Virtru is unreachable', async () => {
await expect(
new VirtruAttributeStore(mkClient({ isAvailable: jest.fn().mockResolvedValue(false) })).assertCanModifyRoom(
{ _id: 'r', abacAttributes: [{ key: 'k', values: ['v'] }] },
actor,
),
).rejects.toMatchObject({ code: 'error-pdp-unavailable' });
});
});

View File

@@ -0,0 +1,187 @@
import type { AbacActor } from '@rocket.chat/core-services';
import type { IAbacAttributeDefinition, IRoom, IRoomAbacRedaction } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import mem from 'mem';
import {
AbacEntityResolutionFailedError,
AbacInvalidAttributeValuesError,
AbacNotAuthorizedToModifyRoomError,
PdpUnavailableError,
} from '../errors';
import { logger } from '../logger';
import type { AttributeEntitlements, IAttributeStore, ListAttributesOptions, ListAttributesResult } from './types';
import type { VirtruClient } from '../clients/virtru/VirtruClient';
import { buildAttributeFqns, buildEntityIdentifier, getUserEntityKey, parseAttributeFqns } from '../clients/virtru/identity';
import type { IGetDecisionBulkRequest, IGetDecisionBulkResponse, IGetEntitlementsRequest, IGetEntitlementsResponse } from '../pdp/types';
const storeLogger = logger.section('VirtruAttributeStore');
const ENTITLEMENTS_CACHE_MS = 15_000;
export class VirtruAttributeStore implements IAttributeStore {
private client: VirtruClient;
private readonly entitlementsCache = new Map<string, { data: Promise<IAbacAttributeDefinition[]>; maxAge: number }>();
private _entitlementsForEntity: (entityId: string) => Promise<IAbacAttributeDefinition[]>;
constructor(client: VirtruClient) {
this.client = client;
this._entitlementsForEntity = mem(
(entityId: string) => {
const p = this.fetchEntitlements(entityId);
p.catch(() => {
this.entitlementsCache.delete(entityId);
});
return p;
},
{
maxAge: ENTITLEMENTS_CACHE_MS,
cacheKey: (args: [string]) => args[0],
cache: this.entitlementsCache,
},
);
}
private async resolveEntityId(actor: AbacActor): Promise<string> {
const { defaultEntityKey } = this.client.getConfig();
const fullUser = await Users.findOneById(actor._id, { projection: { _id: 1, emails: 1, username: 1 } });
const entityKey = fullUser && getUserEntityKey(defaultEntityKey, fullUser);
if (!entityKey) {
throw new AbacEntityResolutionFailedError();
}
return entityKey;
}
private async fetchEntitlements(entityId: string): Promise<IAbacAttributeDefinition[]> {
if (!(await this.client.isAvailable())) {
throw new PdpUnavailableError();
}
const { defaultEntityKey } = this.client.getConfig();
const request: IGetEntitlementsRequest = {
entityIdentifier: {
entityChain: {
entities: [buildEntityIdentifier(defaultEntityKey, entityId)],
},
},
withComprehensiveHierarchy: true,
};
const res = await this.client.apiCall<IGetEntitlementsResponse>('/authorization.v2.AuthorizationService/GetEntitlements', request);
const { attributes, malformed } = parseAttributeFqns(Object.keys(res.entitlements?.[0]?.actionsPerAttributeValueFqn ?? {}));
if (malformed.length) {
storeLogger.warn({ msg: 'Virtru store: ignoring malformed attribute FQNs', malformed });
}
return attributes;
}
private async getEntitlements(actor: AbacActor): Promise<IAbacAttributeDefinition[]> {
const entityId = await this.resolveEntityId(actor);
return this._entitlementsForEntity(entityId);
}
async list(actor: AbacActor | undefined, opts?: ListAttributesOptions): Promise<ListAttributesResult> {
if (!actor) {
throw new AbacEntityResolutionFailedError();
}
let attributes = await this.getEntitlements(actor);
const filter = (opts?.key ?? opts?.values)?.trim().toLowerCase();
if (filter) {
attributes = attributes
.map((a) => ({ key: a.key, values: a.values.filter((v) => v.toLowerCase().includes(filter)) }))
.filter((a) => a.key.toLowerCase().includes(filter) || a.values.length > 0);
}
const total = attributes.length;
const offset = opts?.offset ?? 0;
const count = opts?.count ?? total;
const slice = attributes.slice(offset, offset + count);
return {
attributes: slice.map((a) => ({ _id: a.key, ...a })),
offset,
count: slice.length,
total,
};
}
async entitlementsOf(actor: AbacActor): Promise<AttributeEntitlements> {
const defs = await this.getEntitlements(actor);
return new Map(defs.map((d) => [d.key, new Set(d.values)]));
}
onStoreSelected(): void {
this.entitlementsCache.clear();
}
async validateAssignable(attrs: IAbacAttributeDefinition[], actor: AbacActor): Promise<void> {
const owned = await this.entitlementsOf(actor);
for (const a of attrs) {
const allowed = owned.get(a.key);
if (!allowed || !a.values.every((v) => allowed.has(v))) {
throw new AbacInvalidAttributeValuesError();
}
}
}
async scopeRoomsPage<T extends Pick<IRoom, '_id' | 'abacAttributes'>>(
rooms: T[],
actor: AbacActor,
): Promise<Array<T & IRoomAbacRedaction>> {
if (!rooms.length) {
return rooms;
}
const permitted = await this.decideRooms(rooms, actor).catch((err) => {
storeLogger.warn({ msg: 'Virtru store: redacting all rooms after decision failure', err });
return new Set<string>();
});
return rooms.map((r) => (permitted.has(r._id) ? r : { ...r, abacAttributes: [], abacAttributesRedacted: true }));
}
async assertCanModifyRoom(room: Pick<IRoom, '_id' | 'abacAttributes'>, actor: AbacActor): Promise<void> {
if (!room.abacAttributes?.length) {
return;
}
const permitted = await this.decideRooms([room], actor);
if (!permitted.has(room._id)) {
throw new AbacNotAuthorizedToModifyRoomError();
}
}
private async decideRooms(rooms: Array<Pick<IRoom, '_id' | 'abacAttributes'>>, actor: AbacActor): Promise<Set<string>> {
if (!(await this.client.isAvailable())) {
throw new PdpUnavailableError();
}
const cfg = this.client.getConfig();
const entityId = await this.resolveEntityId(actor);
const requests: IGetDecisionBulkRequest[] = rooms.map((room) => ({
entityIdentifier: {
entityChain: {
entities: [buildEntityIdentifier(cfg.defaultEntityKey, entityId)],
},
},
action: { name: 'read' },
resources: [
{
ephemeralId: room._id,
attributeValues: { fqns: buildAttributeFqns(cfg.attributeNamespace, room.abacAttributes ?? []) },
},
],
}));
let res: IGetDecisionBulkResponse;
try {
res = await this.client.apiCall<IGetDecisionBulkResponse>('/authorization.v2.AuthorizationService/GetDecisionBulk', {
decisionRequests: requests,
});
} catch (err) {
throw new PdpUnavailableError();
}
const permitted = new Set<string>();
for (const dr of res.decisionResponses ?? []) {
for (const rd of dr.resourceDecisions ?? []) {
if (rd.decision === 'DECISION_PERMIT' && rd.ephemeralResourceId) {
permitted.add(rd.ephemeralResourceId);
}
}
}
return permitted;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

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