diff --git a/.changeset/tall-singers-roll.md b/.changeset/tall-singers-roll.md
new file mode 100644
index 0000000000000..dcab473089e96
--- /dev/null
+++ b/.changeset/tall-singers-roll.md
@@ -0,0 +1,10 @@
+---
+"@rocket.chat/meteor": minor
+"@rocket.chat/core-services": minor
+"@rocket.chat/core-typings": minor
+"@rocket.chat/i18n": minor
+"@rocket.chat/authorization-service": minor
+"@rocket.chat/abac": minor
+---
+
+Adds support for setting up Virtru as a PDP (Policy Decision Point) for ABAC.
diff --git a/.github/workflows/ci-test-e2e.yml b/.github/workflows/ci-test-e2e.yml
index 23ddeef27dd12..a933a25f4a088 100644
--- a/.github/workflows/ci-test-e2e.yml
+++ b/.github/workflows/ci-test-e2e.yml
@@ -182,6 +182,7 @@ jobs:
env:
ENTERPRISE_LICENSE: ${{ inputs.enterprise-license }}
TRANSPORTER: ${{ inputs.transporter }}
+ COMPOSE_PROFILES: ${{ inputs.type == 'api' && 'api' || '' }}
run: |
DEBUG_LOG_LEVEL=${DEBUG_LOG_LEVEL:-0} docker compose -f docker-compose-ci.yml up -d --wait
diff --git a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx
index 016b3c128e4ce..7d834b9075188 100644
--- a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx
+++ b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx
@@ -1,4 +1,5 @@
import { Accordion, AccordionItem, Box, Callout, FieldGroup } from '@rocket.chat/fuselage';
+import { useSetting } from '@rocket.chat/ui-contexts';
import { useTranslation, Trans } from 'react-i18next';
import AbacEnabledToggle from './AbacEnabledToggle';
@@ -9,13 +10,27 @@ import { links } from '../../../../lib/links';
const SettingsPage = () => {
const { t } = useTranslation();
const { data: hasABAC = false } = useHasLicenseModule('abac');
+ const pdpType = useSetting('ABAC_PDP_Type', 'local');
+
return (
+
+ {pdpType === 'local' && (
+
+
+ User attributes are synchronized via LDAP
+
+ Learn more
+
+
+
+ )}
+
@@ -24,16 +39,18 @@ const SettingsPage = () => {
-
-
-
- User attributes are synchronized via LDAP
-
- Learn more
-
-
-
+
+
+
+
+
+
+
+
+
+
+
);
diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts
index 0ed503678f527..43307ea132106 100644
--- a/apps/meteor/ee/server/api/abac/index.ts
+++ b/apps/meteor/ee/server/api/abac/index.ts
@@ -1,3 +1,4 @@
+import { PdpHealthCheckError } from '@rocket.chat/abac';
import { Abac } from '@rocket.chat/core-services';
import type { AbacActor } from '@rocket.chat/core-services';
import type { IServerEvents, IUser } from '@rocket.chat/core-typings';
@@ -22,6 +23,8 @@ import {
GETAbacRoomsResponseValidator,
GETAbacAuditEventsQuerySchema,
GETAbacAuditEventsResponseSchema,
+ GETAbacPdpHealthResponseSchema,
+ GETAbacPdpHealthErrorResponseSchema,
} from './schemas';
import { API } from '../../../../app/api/server';
import type { ExtractRoutesFromAPI } from '../../../../app/api/server/ApiClass';
@@ -357,6 +360,32 @@ const abacEndpoints = API.v1
return API.v1.success(result);
},
)
+ .get(
+ 'abac/pdp/health',
+ {
+ authRequired: true,
+ permissionsRequired: ['abac-management'],
+ rateLimiterOptions: {
+ numRequestsAllowed: 5,
+ intervalTimeInMS: 60000,
+ },
+ response: {
+ 200: GETAbacPdpHealthResponseSchema,
+ 400: GETAbacPdpHealthErrorResponseSchema,
+ 401: validateUnauthorizedErrorResponse,
+ 403: validateUnauthorizedErrorResponse,
+ },
+ },
+ async function action() {
+ try {
+ await Abac.getPDPHealth();
+ return API.v1.success({ available: true, message: 'ABAC_PDP_Health_OK' });
+ } catch (err) {
+ const message = err instanceof PdpHealthCheckError ? err.errorCode : 'ABAC_PDP_Health_Not_OK';
+ return API.v1.failure({ available: false, message });
+ }
+ },
+ )
.get(
'abac/audit',
{
diff --git a/apps/meteor/ee/server/api/abac/schemas.ts b/apps/meteor/ee/server/api/abac/schemas.ts
index 4d81610452a8e..582b43291d1f3 100644
--- a/apps/meteor/ee/server/api/abac/schemas.ts
+++ b/apps/meteor/ee/server/api/abac/schemas.ts
@@ -342,6 +342,31 @@ export const POSTAbacUsersSyncBodySchema = ajv.compile<{
export const GenericErrorSchema = ajv.compile<{ success: boolean; message: string }>(GenericError);
+export const GETAbacPdpHealthResponseSchema = ajv.compile<{
+ available: boolean;
+ message: string;
+}>({
+ type: 'object',
+ properties: {
+ success: { type: 'boolean', enum: [true] },
+ available: { type: 'boolean' },
+ message: { type: 'string' },
+ },
+ required: ['success', 'available', 'message'],
+ additionalProperties: false,
+});
+
+export const GETAbacPdpHealthErrorResponseSchema = ajv.compile<{ available: boolean; message: string }>({
+ type: 'object',
+ properties: {
+ success: { type: 'boolean', enum: [false] },
+ available: { type: 'boolean', enum: [false] },
+ message: { type: 'string' },
+ },
+ required: ['success', 'available', 'message'],
+ additionalProperties: false,
+});
+
const GETAbacRoomsListQuerySchema = {
type: 'object',
properties: {
diff --git a/apps/meteor/ee/server/configuration/abac.ts b/apps/meteor/ee/server/configuration/abac.ts
index b2f0c1aa3ab80..576e29797265a 100644
--- a/apps/meteor/ee/server/configuration/abac.ts
+++ b/apps/meteor/ee/server/configuration/abac.ts
@@ -1,12 +1,19 @@
+import { Abac } from '@rocket.chat/core-services';
+import { cronJobs } from '@rocket.chat/cron';
import { License } from '@rocket.chat/license';
import { Users } from '@rocket.chat/models';
+import { isValidCron } from 'cron-validator';
import { Meteor } from 'meteor/meteor';
import { settings } from '../../../app/settings/server';
import { LDAPEE } from '../sdk';
+const VIRTRU_PDP_SYNC_JOB = 'ABAC_Virtru_PDP_Sync';
+
Meteor.startup(async () => {
let stopWatcher: () => void;
+ let stopCronWatcher: () => void;
+
License.onToggledFeature('abac', {
up: async () => {
const { addSettings } = await import('../settings/abac');
@@ -18,13 +25,44 @@ Meteor.startup(async () => {
await import('../hooks/abac');
stopWatcher = settings.watch('ABAC_Enabled', async (value) => {
- if (value) {
+ if (value && settings.get('ABAC_PDP_Type') !== 'virtru') {
await LDAPEE.syncUsersAbacAttributes(Users.findLDAPUsers());
}
});
+
+ async function configureVirtruPdpSync(): Promise {
+ if (await cronJobs.has(VIRTRU_PDP_SYNC_JOB)) {
+ await cronJobs.remove(VIRTRU_PDP_SYNC_JOB);
+ }
+
+ const abacEnabled = settings.get('ABAC_Enabled');
+ const pdpType = settings.get('ABAC_PDP_Type');
+
+ if (!abacEnabled || pdpType !== 'virtru') {
+ return;
+ }
+
+ const cronValue = settings.get('ABAC_Virtru_Sync_Interval');
+
+ if (!cronValue || !isValidCron(cronValue)) {
+ return;
+ }
+
+ await cronJobs.add(VIRTRU_PDP_SYNC_JOB, cronValue, () => Abac.evaluateRoomMembership());
+ }
+
+ stopCronWatcher = settings.watchMultiple(
+ ['ABAC_Enabled', 'ABAC_PDP_Type', 'ABAC_Virtru_Sync_Interval'],
+ () => void configureVirtruPdpSync(),
+ );
},
- down: () => {
+ down: async () => {
stopWatcher?.();
+ stopCronWatcher?.();
+
+ if (await cronJobs.has(VIRTRU_PDP_SYNC_JOB)) {
+ await cronJobs.remove(VIRTRU_PDP_SYNC_JOB);
+ }
},
});
});
diff --git a/apps/meteor/ee/server/lib/ldap/Manager.ts b/apps/meteor/ee/server/lib/ldap/Manager.ts
index a8ec3d84d9769..a5f36f6788467 100644
--- a/apps/meteor/ee/server/lib/ldap/Manager.ts
+++ b/apps/meteor/ee/server/lib/ldap/Manager.ts
@@ -109,7 +109,8 @@ export class LDAPEEManager extends LDAPManager {
!settings.get('LDAP_Enable') ||
!settings.get('LDAP_Background_Sync_ABAC_Attributes') ||
!License.hasModule('abac') ||
- !settings.get('ABAC_Enabled')
+ !settings.get('ABAC_Enabled') ||
+ settings.get('ABAC_PDP_Type') === 'virtru'
) {
return;
}
@@ -129,7 +130,12 @@ export class LDAPEEManager extends LDAPManager {
}
public static async syncUsersAbacAttributes(users: FindCursor): Promise {
- if (!settings.get('LDAP_Enable') || !License.hasModule('abac') || !settings.get('ABAC_Enabled')) {
+ if (
+ !settings.get('LDAP_Enable') ||
+ !License.hasModule('abac') ||
+ !settings.get('ABAC_Enabled') ||
+ settings.get('ABAC_PDP_Type') === 'virtru'
+ ) {
return;
}
diff --git a/apps/meteor/ee/server/settings/abac.ts b/apps/meteor/ee/server/settings/abac.ts
index 54b93912ddb7c..6dcbaf93c2f83 100644
--- a/apps/meteor/ee/server/settings/abac.ts
+++ b/apps/meteor/ee/server/settings/abac.ts
@@ -1,5 +1,8 @@
import { settingsRegistry } from '../../../app/settings/server';
+const abacEnabledQuery = { _id: 'ABAC_Enabled', value: true };
+const virtruPdpQuery = [abacEnabledQuery, { _id: 'ABAC_PDP_Type', value: 'virtru' }];
+
export function addSettings(): Promise {
return settingsRegistry.addGroup('General', async function () {
await this.with(
@@ -15,20 +18,101 @@ export function addSettings(): Promise {
section: 'ABAC',
i18nDescription: 'ABAC_Enabled_Description',
});
+ await this.add('ABAC_PDP_Type', 'local', {
+ type: 'select',
+ public: true,
+ section: 'ABAC',
+ invalidValue: 'local',
+ values: [
+ { key: 'local', i18nLabel: 'ABAC_PDP_Type_Local' },
+ { key: 'virtru', i18nLabel: 'ABAC_PDP_Type_Virtru' },
+ ],
+ enableQuery: abacEnabledQuery,
+ });
await this.add('ABAC_ShowAttributesInRooms', false, {
type: 'boolean',
public: true,
invalidValue: false,
section: 'ABAC',
- enableQuery: { _id: 'ABAC_Enabled', value: true },
+ enableQuery: abacEnabledQuery,
});
await this.add('Abac_Cache_Decision_Time_Seconds', 300, {
type: 'int',
public: true,
section: 'ABAC',
invalidValue: 0,
- enableQuery: { _id: 'ABAC_Enabled', value: true },
+ enableQuery: abacEnabledQuery,
+ });
+
+ // Virtru PDP Configuration
+ await this.add('ABAC_Virtru_Base_URL', '', {
+ type: 'string',
+ public: false,
+ invalidValue: '',
+ section: 'ABAC_Virtru_PDP_Configuration',
+ enableQuery: virtruPdpQuery,
+ });
+ await this.add('ABAC_Virtru_Client_ID', '', {
+ type: 'string',
+ public: false,
+ invalidValue: '',
+ section: 'ABAC_Virtru_PDP_Configuration',
+ enableQuery: virtruPdpQuery,
+ });
+ await this.add('ABAC_Virtru_Client_Secret', '', {
+ type: 'password',
+ public: false,
+ invalidValue: '',
+ section: 'ABAC_Virtru_PDP_Configuration',
+ enableQuery: virtruPdpQuery,
+ });
+ await this.add('ABAC_Virtru_OIDC_Endpoint', '', {
+ type: 'string',
+ public: false,
+ invalidValue: '',
+ section: 'ABAC_Virtru_PDP_Configuration',
+ i18nDescription: 'ABAC_Virtru_OIDC_Endpoint_Description',
+ enableQuery: virtruPdpQuery,
+ });
+ await this.add('ABAC_Virtru_Default_Entity_Key', 'emailAddress', {
+ type: 'select',
+ public: false,
+ invalidValue: 'emailAddress',
+ section: 'ABAC_Virtru_PDP_Configuration',
+ i18nDescription: 'ABAC_Virtru_Default_Entity_Key_Description',
+ values: [
+ { key: 'emailAddress', i18nLabel: 'ABAC_Virtru_Entity_Key_Email' },
+ { key: 'oidcIdentifier', i18nLabel: 'ABAC_Virtru_Entity_Key_OIDC' },
+ ],
+ enableQuery: virtruPdpQuery,
+ });
+ await this.add('ABAC_Virtru_Attribute_Namespace', 'example.com', {
+ type: 'string',
+ public: false,
+ invalidValue: 'example.com',
+ section: 'ABAC_Virtru_PDP_Configuration',
+ i18nDescription: 'ABAC_Virtru_Attribute_Namespace_Description',
+ enableQuery: virtruPdpQuery,
+ });
+ await this.add('ABAC_Virtru_Sync_Interval', '*/5 * * * *', {
+ type: 'string',
+ public: false,
+ invalidValue: '*/5 * * * *',
+ section: 'ABAC_Virtru_PDP_Configuration',
+ i18nDescription: 'ABAC_Virtru_Sync_Interval_Description',
+ enableQuery: virtruPdpQuery,
});
+ await this.add(
+ 'ABAC_Virtru_Test_Connection',
+ { method: 'GET', path: '/v1/abac/pdp/health' },
+ {
+ type: 'action',
+ actionText: 'ABAC_Virtru_Test_Connection_Action',
+ invalidValue: '',
+ section: 'ABAC_Virtru_PDP_Configuration',
+ enableQuery: virtruPdpQuery,
+ },
+ );
},
);
});
diff --git a/apps/meteor/tests/data/mock-server.helper.ts b/apps/meteor/tests/data/mock-server.helper.ts
new file mode 100644
index 0000000000000..f5f06b0557aa7
--- /dev/null
+++ b/apps/meteor/tests/data/mock-server.helper.ts
@@ -0,0 +1,87 @@
+import { MOCK_SERVER_URL } from '../e2e/config/constants';
+
+type Decision = 'DECISION_PERMIT' | 'DECISION_DENY';
+
+export const mockServerSet = async (method: string, path: string, body: unknown, statusCode = 200, times = 0): Promise => {
+ const res = await fetch(`${MOCK_SERVER_URL}/__mock/set`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ method,
+ path,
+ response: { status_code: statusCode, body, times },
+ }),
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to program mock-server: ${res.status} ${await res.text()}`);
+ }
+};
+
+export const mockServerSetMany = async (
+ mocks: Array<{ method: string; path: string; body: unknown; statusCode?: number; times?: number }>,
+): Promise => {
+ const res = await fetch(`${MOCK_SERVER_URL}/__mock/set-many`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(
+ mocks.map((m) => ({
+ method: m.method,
+ path: m.path,
+ response: { status_code: m.statusCode ?? 200, body: m.body, times: m.times ?? 0 },
+ })),
+ ),
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to program mock-server (set-many): ${res.status} ${await res.text()}`);
+ }
+};
+
+export const mockServerReset = async (): Promise => {
+ await fetch(`${MOCK_SERVER_URL}/__mock/reset`, { method: 'DELETE' });
+};
+
+export const mockServerHealthy = async (): Promise => {
+ try {
+ const res = await fetch(`${MOCK_SERVER_URL}/__mock/health`);
+ return res.ok;
+ } catch {
+ return false;
+ }
+};
+
+export const seedDefaultMocks = async () => {
+ await mockServerSetMany([
+ {
+ method: 'GET',
+ path: '/healthz',
+ body: { status: 'SERVING' },
+ },
+ {
+ method: 'POST',
+ path: '/auth/realms/mock/protocol/openid-connect/token',
+ body: { access_token: 'mock-pdp-token', token_type: 'Bearer', expires_in: 3600 },
+ },
+ ]);
+};
+
+export const seedGetDecisions = async (decision: Decision, times = 0) => {
+ await mockServerSet('POST', '/authorization.AuthorizationService/GetDecisions', { decisionResponses: [{ decision }] }, 200, times);
+};
+
+export const seedGetDecisionBulk = async (
+ responses: Array<{ resourceDecisions: Array<{ decision: Decision; ephemeralResourceId?: string }> }>,
+ times = 0,
+) => {
+ await mockServerSet('POST', '/authorization.v2.AuthorizationService/GetDecisionBulk', { decisionResponses: responses }, 200, times);
+};
+
+export const seedBulkDecisionByEntity = async (permitValues: string[], defaultDecision: Decision = 'DECISION_DENY') => {
+ const res = await fetch(`${MOCK_SERVER_URL}/__mock/set-bulk-decision`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ permit_values: permitValues, default_decision: defaultDecision }),
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to program mock-server (set-bulk-decision): ${res.status} ${await res.text()}`);
+ }
+};
diff --git a/apps/meteor/tests/e2e/config/constants.ts b/apps/meteor/tests/e2e/config/constants.ts
index 4c2a174e98aa2..8cd9088956da6 100644
--- a/apps/meteor/tests/e2e/config/constants.ts
+++ b/apps/meteor/tests/e2e/config/constants.ts
@@ -10,6 +10,8 @@ export const IS_EE = process.env.IS_EE ? !!JSON.parse(process.env.IS_EE) : false
export const URL_MONGODB = process.env.MONGO_URL || 'mongodb://localhost:3001/meteor?retryWrites=false';
+export const MOCK_SERVER_URL = process.env.MOCK_SERVER_URL || 'http://localhost:4000';
+
export const ADMIN_CREDENTIALS = {
email: 'rocketchat.internal.admin.test@rocket.chat',
password: 'rocketchat.internal.admin.test',
diff --git a/apps/meteor/tests/end-to-end/api/abac.ts b/apps/meteor/tests/end-to-end/api/abac.ts
index bb4d9ee8d4799..dfba6cc11867e 100644
--- a/apps/meteor/tests/end-to-end/api/abac.ts
+++ b/apps/meteor/tests/end-to-end/api/abac.ts
@@ -6,10 +6,19 @@ import { MongoClient } from 'mongodb';
import { getCredentials, request, credentials, methodCall } from '../../data/api-data';
import { sleep } from '../../data/livechat/utils';
+import {
+ mockServerHealthy,
+ mockServerReset,
+ mockServerSet,
+ seedBulkDecisionByEntity,
+ seedDefaultMocks,
+ seedGetDecisionBulk,
+ seedGetDecisions,
+} from '../../data/mock-server.helper';
import { updatePermission, updateSetting } from '../../data/permissions.helper';
import { createRoom, deleteRoom } from '../../data/rooms.helper';
import { deleteTeam } from '../../data/teams.helper';
-import { password } from '../../data/user';
+import { adminEmail, password } from '../../data/user';
import { createUser, deleteUser, login } from '../../data/users.helper';
import { IS_EE, URL_MONGODB } from '../../e2e/config/constants';
@@ -2614,3 +2623,439 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I
});
});
});
+
+(IS_EE ? describe : describe.skip)('[ABAC] External PDP (mock-server)', function () {
+ this.retries(0);
+
+ const attrKey = `ext_pdp_attr_${Date.now()}`;
+
+ before((done) => {
+ getCredentials(done);
+ });
+
+ before(async function () {
+ this.timeout(15000);
+
+ const healthy = await mockServerHealthy();
+ expect(healthy, 'mock-server is not reachable — ensure it is running').to.be.true;
+
+ await updatePermission('abac-management', ['admin']);
+ await updateSetting('ABAC_Enabled', true);
+ await updateSetting('ABAC_PDP_Type', 'virtru');
+ await Promise.all([
+ updateSetting('ABAC_Virtru_Base_URL', 'http://mock-server:8080'),
+ updateSetting('ABAC_Virtru_OIDC_Endpoint', 'http://mock-server:8080/auth/realms/mock'),
+ updateSetting('ABAC_Virtru_Client_ID', 'mock-client'),
+ updateSetting('ABAC_Virtru_Client_Secret', 'mock-secret'),
+ updateSetting('ABAC_Virtru_Default_Entity_Key', 'emailAddress'),
+ updateSetting('ABAC_Virtru_Attribute_Namespace', 'example.com'),
+ updateSetting('Abac_Cache_Decision_Time_Seconds', 0),
+ ]);
+
+ await request
+ .post('/api/v1/abac/attributes')
+ .set(credentials)
+ .send({ key: attrKey, values: ['alpha', 'beta', 'gamma'] })
+ .expect(200);
+ });
+
+ after(async function () {
+ this.timeout(10000);
+
+ await mockServerReset();
+ await updateSetting('ABAC_PDP_Type', 'local');
+ await updateSetting('ABAC_Enabled', false);
+ });
+
+ describe('PERMIT all: users remain when PDP permits everyone', () => {
+ let room: IRoom;
+ let user: IUser;
+ let userCreds: Credentials;
+
+ before(async function () {
+ this.timeout(15000);
+
+ user = await createUser();
+ userCreds = await login(user.username, password);
+
+ room = (await createRoom({ type: 'p', name: `extpdp-permit-${Date.now()}` })).body.group;
+
+ await request
+ .post('/api/v1/groups.invite')
+ .set(credentials)
+ .send({ roomId: room._id, usernames: [user.username] })
+ .expect(200);
+
+ await mockServerReset();
+ await seedDefaultMocks();
+ await seedGetDecisionBulk([
+ { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] },
+ { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] },
+ ]);
+
+ await request
+ .post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`)
+ .set(credentials)
+ .send({ values: ['alpha'] })
+ .expect(200);
+ });
+
+ after(async () => {
+ await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(user)]);
+ });
+
+ it('room creator remains in the room', async () => {
+ const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200);
+
+ const memberIds = res.body.members.map((m: IUser) => m._id);
+ expect(memberIds).to.include(credentials['X-User-Id']);
+ });
+
+ it('user remains in the room', async () => {
+ const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200);
+
+ const usernames = res.body.members.map((m: IUser) => m.username);
+ expect(usernames).to.include(user.username);
+ });
+
+ it('user can access room history when PDP returns PERMIT', async () => {
+ await mockServerReset();
+ await seedDefaultMocks();
+ await seedGetDecisions('DECISION_PERMIT');
+
+ await request
+ .get('/api/v1/groups.history')
+ .set(userCreds)
+ .query({ roomId: room._id })
+ .expect(200)
+ .expect((res) => {
+ expect(res.body).to.have.property('success', true);
+ expect(res.body).to.have.property('messages').that.is.an('array');
+ });
+ });
+ });
+
+ describe('Access check: PDP DENY removes user on room access', () => {
+ let room: IRoom;
+ let user: IUser;
+ let userCreds: Credentials;
+
+ before(async function () {
+ this.timeout(15000);
+
+ user = await createUser();
+ userCreds = await login(user.username, password);
+
+ room = (await createRoom({ type: 'p', name: `extpdp-access-${Date.now()}` })).body.group;
+
+ await request
+ .post('/api/v1/groups.invite')
+ .set(credentials)
+ .send({ roomId: room._id, usernames: [user.username] })
+ .expect(200);
+
+ await mockServerReset();
+ await seedDefaultMocks();
+ await seedGetDecisionBulk([
+ { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] },
+ { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] },
+ ]);
+
+ await request
+ .post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`)
+ .set(credentials)
+ .send({ values: ['alpha'] })
+ .expect(200);
+ });
+
+ after(async () => {
+ await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(user)]);
+ });
+
+ it('user loses access when PDP flips to DENY', async () => {
+ await mockServerReset();
+ await seedDefaultMocks();
+ await seedGetDecisions('DECISION_DENY');
+
+ await request
+ .get('/api/v1/groups.history')
+ .set(userCreds)
+ .query({ roomId: room._id })
+ .expect(403)
+ .expect((res) => {
+ expect(res.body).to.have.property('success', false);
+ });
+ });
+
+ it('user is removed from room after access DENY', async () => {
+ const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200);
+
+ const usernames = res.body.members.map((m: IUser) => m.username);
+ expect(usernames).to.not.include(user.username);
+ });
+ });
+
+ describe('Invite to ABAC room: PDP decides who can join', () => {
+ let room: IRoom;
+ let permitUser: IUser;
+ let denyUser: IUser;
+
+ before(async function () {
+ this.timeout(10000);
+
+ permitUser = await createUser();
+ denyUser = await createUser();
+
+ room = (await createRoom({ type: 'p', name: `extpdp-invite-${Date.now()}` })).body.group;
+ await mockServerReset();
+ await seedDefaultMocks();
+ await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }]);
+ await request
+ .post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`)
+ .set(credentials)
+ .send({ values: ['alpha'] })
+ .expect(200);
+ });
+
+ after(async () => {
+ await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(permitUser), deleteUser(denyUser)]);
+ });
+
+ it('should allow invite when PDP returns PERMIT', async () => {
+ await mockServerReset();
+ await seedDefaultMocks();
+ await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }]);
+
+ await request
+ .post('/api/v1/groups.invite')
+ .set(credentials)
+ .send({ roomId: room._id, usernames: [permitUser.username] })
+ .expect(200)
+ .expect((res) => {
+ expect(res.body).to.have.property('success', true);
+ });
+ });
+
+ it('invited user is a member of the room after PERMIT', async () => {
+ const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200);
+
+ const usernames = res.body.members.map((m: IUser) => m.username);
+ expect(usernames).to.include(permitUser.username);
+ });
+
+ it('should reject invite when PDP returns DENY', async () => {
+ await mockServerReset();
+ await seedDefaultMocks();
+ await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_DENY', ephemeralResourceId: room._id }] }]);
+
+ await request
+ .post('/api/v1/groups.invite')
+ .set(credentials)
+ .send({ roomId: room._id, usernames: [denyUser.username] })
+ .expect(400)
+ .expect((res) => {
+ expect(res.body).to.have.property('success', false);
+ expect(res.body).to.have.property('errorType', 'error-only-compliant-users-can-be-added-to-abac-rooms');
+ });
+ });
+
+ it('denied user is not a member of the room', async () => {
+ const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200);
+
+ const usernames = res.body.members.map((m: IUser) => m.username);
+ expect(usernames).to.not.include(denyUser.username);
+ });
+
+ it('room creator remains after invite operations', async () => {
+ const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200);
+
+ const memberIds = res.body.members.map((m: IUser) => m._id);
+ expect(memberIds).to.include(credentials['X-User-Id']);
+ });
+ });
+
+ describe('PDP unavailability: fail-closed behavior', () => {
+ let room: IRoom;
+ let user: IUser;
+ let userCredentials: Credentials;
+
+ before(async function () {
+ this.timeout(10000);
+
+ user = await createUser();
+ userCredentials = await login(user.username, password);
+
+ room = (await createRoom({ type: 'p', name: `extpdp-failclose-${Date.now()}` })).body.group;
+ await request
+ .post('/api/v1/groups.invite')
+ .set(credentials)
+ .send({ roomId: room._id, usernames: [user.username] })
+ .expect(200);
+
+ await mockServerReset();
+ await seedDefaultMocks();
+ await seedGetDecisionBulk([
+ { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] },
+ { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] },
+ ]);
+ await request
+ .post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`)
+ .set(credentials)
+ .send({ values: ['alpha'] })
+ .expect(200);
+ });
+
+ after(async () => {
+ await mockServerReset();
+ await seedDefaultMocks();
+ await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(user)]);
+ });
+
+ it('should deny access when PDP health check returns NOT_SERVING', async () => {
+ await mockServerReset();
+ await mockServerSet('GET', '/healthz', { status: 'NOT_SERVING' });
+
+ await request
+ .get('/api/v1/groups.history')
+ .set(userCredentials)
+ .query({ roomId: room._id })
+ .expect(403)
+ .expect((res) => {
+ expect(res.body).to.have.property('success', false);
+ });
+ });
+
+ it('should deny invite when PDP is unavailable', async () => {
+ await mockServerReset();
+ await mockServerSet('GET', '/healthz', { status: 'NOT_SERVING' });
+
+ const newUser = await createUser();
+
+ await request
+ .post('/api/v1/groups.invite')
+ .set(credentials)
+ .send({ roomId: room._id, usernames: [newUser.username!] })
+ .expect(400)
+ .expect((res) => {
+ expect(res.body).to.have.property('success', false);
+ });
+
+ await deleteUser(newUser);
+ });
+
+ it('should deny room attribute changes when PDP is unavailable', async () => {
+ await mockServerReset();
+ await mockServerSet('GET', '/healthz', { status: 'NOT_SERVING' });
+
+ await request
+ .post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`)
+ .set(credentials)
+ .send({ values: ['beta'] })
+ .expect(400)
+ .expect((res) => {
+ expect(res.body).to.have.property('success', false);
+ expect(res.body).to.have.property('error', 'error-pdp-unavailable');
+ });
+ });
+ });
+
+ describe('Selective DENY: only non-permitted users are removed', () => {
+ let room: IRoom;
+ let user: IUser;
+
+ before(async function () {
+ this.timeout(15000);
+
+ user = await createUser();
+ room = (await createRoom({ type: 'p', name: `extpdp-selective-${Date.now()}` })).body.group;
+ await request
+ .post('/api/v1/groups.invite')
+ .set(credentials)
+ .send({ roomId: room._id, usernames: [user.username] })
+ .expect(200);
+
+ await mockServerReset();
+ await seedDefaultMocks();
+ await seedBulkDecisionByEntity([adminEmail], 'DECISION_DENY');
+
+ await request
+ .post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`)
+ .set(credentials)
+ .send({ values: ['alpha'] })
+ .expect(200);
+ });
+
+ after(async () => {
+ await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(user)]);
+ });
+
+ it('admin (permitted) remains in the room', async () => {
+ const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200);
+ const memberIds = res.body.members.map((m: IUser) => m._id);
+ expect(memberIds).to.include(credentials['X-User-Id']);
+ });
+
+ it('user (denied) was removed from the room', async () => {
+ const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200);
+ const usernames = res.body.members.map((m: IUser) => m.username);
+ expect(usernames).to.not.include(user.username);
+ });
+ });
+
+ describe('Tightening attributes: selective DENY removes only denied users', () => {
+ let room: IRoom;
+ let user: IUser;
+
+ before(async function () {
+ this.timeout(15000);
+
+ user = await createUser();
+ room = (await createRoom({ type: 'p', name: `extpdp-tighten-${Date.now()}` })).body.group;
+ await request
+ .post('/api/v1/groups.invite')
+ .set(credentials)
+ .send({ roomId: room._id, usernames: [user.username] })
+ .expect(200);
+
+ await mockServerReset();
+ await seedDefaultMocks();
+ await seedGetDecisionBulk([
+ { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] },
+ { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] },
+ ]);
+ await request
+ .post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`)
+ .set(credentials)
+ .send({ values: ['alpha'] })
+ .expect(200);
+ });
+
+ after(async () => {
+ await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(user)]);
+ });
+
+ it('user is removed when attributes are tightened and PDP denies them', async function () {
+ this.timeout(10000);
+
+ await mockServerReset();
+ await seedDefaultMocks();
+ await seedBulkDecisionByEntity([adminEmail], 'DECISION_DENY');
+
+ await request
+ .put(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`)
+ .set(credentials)
+ .send({ values: ['alpha', 'beta'] })
+ .expect(200);
+
+ const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200);
+ const usernames = res.body.members.map((m: IUser) => m.username);
+ expect(usernames).to.not.include(user.username);
+ });
+
+ it('admin remains in the room after tightening', async () => {
+ const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200);
+ const memberIds = res.body.members.map((m: IUser) => m._id);
+ expect(memberIds).to.include(credentials['X-User-Id']);
+ });
+ });
+});
diff --git a/apps/meteor/tests/end-to-end/api/calendar.ts b/apps/meteor/tests/end-to-end/api/calendar.ts
index c15e26b9948d6..930a6e8b80483 100644
--- a/apps/meteor/tests/end-to-end/api/calendar.ts
+++ b/apps/meteor/tests/end-to-end/api/calendar.ts
@@ -4,8 +4,8 @@ import { expect } from 'chai';
import { after, before, describe, it } from 'mocha';
import type { Response } from 'supertest';
-import { sleep } from '../../../lib/utils/sleep';
import { getCredentials, api, request, credentials } from '../../data/api-data';
+import { sleep } from '../../data/livechat/utils';
import { password } from '../../data/user';
import { createUser, deleteUser, login } from '../../data/users.helper';
import { IS_EE } from '../../e2e/config/constants';
diff --git a/docker-compose-ci.yml b/docker-compose-ci.yml
index e5f71d5937c54..32e31b448f9b4 100644
--- a/docker-compose-ci.yml
+++ b/docker-compose-ci.yml
@@ -200,6 +200,18 @@ services:
httpbin:
image: kong/httpbin
+ mock-server:
+ image: kaleman14/mockserver:latest
+ profiles:
+ - api
+ ports:
+ - 4000:8080
+ healthcheck:
+ test: ["CMD", "/mock-server", "-healthcheck"]
+ interval: 2s
+ timeout: 5s
+ retries: 5
+
traefik:
image: traefik:v3.6.6
command:
diff --git a/ee/apps/authorization-service/Dockerfile b/ee/apps/authorization-service/Dockerfile
index d26a97204b1f2..d238054966a16 100644
--- a/ee/apps/authorization-service/Dockerfile
+++ b/ee/apps/authorization-service/Dockerfile
@@ -69,6 +69,12 @@ COPY ./packages/ui-kit/dist packages/ui-kit/dist
COPY ./packages/http-router/package.json packages/http-router/package.json
COPY ./packages/http-router/dist packages/http-router/dist
+COPY ./packages/server-fetch/package.json packages/server-fetch/package.json
+COPY ./packages/server-fetch/dist packages/server-fetch/dist
+
+COPY ./packages/tools/package.json packages/tools/package.json
+COPY ./packages/tools/dist packages/tools/dist
+
COPY ./ee/packages/abac/package.json ee/packages/abac/package.json
COPY ./ee/packages/abac/dist ee/packages/abac/dist
diff --git a/ee/packages/abac/package.json b/ee/packages/abac/package.json
index 026e965a4b886..b31e3633136a8 100644
--- a/ee/packages/abac/package.json
+++ b/ee/packages/abac/package.json
@@ -31,6 +31,8 @@
"@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"
diff --git a/ee/packages/abac/src/audit.ts b/ee/packages/abac/src/audit.ts
index 83977a9af0d78..ad49c115d4340 100644
--- a/ee/packages/abac/src/audit.ts
+++ b/ee/packages/abac/src/audit.ts
@@ -7,6 +7,7 @@ import type {
AbacAuditServerEventKey,
AbacAttributeDefinitionChangeType,
AbacAuditReason,
+ AbacPdpType,
MinimalRoom,
MinimalUser,
AbacActionPerformed,
@@ -122,6 +123,7 @@ export const Audit = {
object: MinimalRoom,
reason: AbacAuditReason = 'room-attributes-change',
actionPerformed: AbacActionPerformed = 'revoked-object-access',
+ pdp?: AbacPdpType,
) => {
return audit(
'abac.action.performed',
@@ -130,6 +132,7 @@ export const Audit = {
reason,
subject,
object,
+ pdp,
},
{ type: 'system' },
);
diff --git a/ee/packages/abac/src/can-access-object.spec.ts b/ee/packages/abac/src/can-access-object.spec.ts
index 49caf2ba1e147..24ed03e1bd717 100644
--- a/ee/packages/abac/src/can-access-object.spec.ts
+++ b/ee/packages/abac/src/can-access-object.spec.ts
@@ -54,6 +54,7 @@ describe('AbacService.canAccessObject (unit)', () => {
beforeEach(() => {
service = new AbacService();
+ service.setPdpStrategy('local');
jest.clearAllMocks();
// Default behaviors
mockSettingsGetValueById.mockResolvedValue(300); // 5 minute cache
diff --git a/ee/packages/abac/src/errors.ts b/ee/packages/abac/src/errors.ts
index 0b7072c39b617..cb9dd22890d0f 100644
--- a/ee/packages/abac/src/errors.ts
+++ b/ee/packages/abac/src/errors.ts
@@ -10,6 +10,7 @@ export enum AbacErrorCode {
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',
}
export class AbacError extends Error {
@@ -91,3 +92,20 @@ export class OnlyCompliantCanBeAddedToRoomError extends AbacError {
super(AbacErrorCode.OnlyCompliantCanBeAddedToRoom, details);
}
}
+
+export class PdpUnavailableError extends AbacError {
+ constructor(details?: unknown) {
+ super(AbacErrorCode.PdpUnavailable, details);
+ }
+}
+
+export class PdpHealthCheckError extends Error {
+ public readonly errorCode: string;
+
+ constructor(errorCode: string) {
+ super(errorCode);
+ this.errorCode = errorCode;
+
+ Object.setPrototypeOf(this, new.target.prototype);
+ }
+}
diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts
index a60df806e782a..cf8e6aae68a47 100644
--- a/ee/packages/abac/src/index.ts
+++ b/ee/packages/abac/src/index.ts
@@ -12,6 +12,7 @@ import type {
} from '@rocket.chat/core-typings';
import { Rooms, AbacAttributes, Users, Subscriptions } from '@rocket.chat/models';
import { escapeRegExp } from '@rocket.chat/string-helpers';
+import { isTruthy } from '@rocket.chat/tools';
import type { Document, UpdateFilter } from 'mongodb';
import pLimit from 'p-limit';
@@ -23,6 +24,8 @@ import {
AbacInvalidAttributeValuesError,
AbacUnsupportedObjectTypeError,
AbacUnsupportedOperationError,
+ PdpUnavailableError,
+ PdpHealthCheckError,
} from './errors';
import {
getAbacRoom,
@@ -34,22 +37,45 @@ import {
MAX_ABAC_ATTRIBUTE_KEYS,
} from './helper';
import { logger } from './logger';
-import type { IPolicyDecisionPoint } from './pdp';
-import { LocalPDP, ExternalPDP } from './pdp';
+import type { IPolicyDecisionPoint, VirtruPDPConfig } from './pdp';
+import { LocalPDP, VirtruPDP } from './pdp';
// Limit concurrent user removals to avoid overloading the server with too many operations at once
const limit = pLimit(20);
+const stripTrailingSlashes = (value: string): string => value.replace(/\/+$/, '');
+
export class AbacService extends ServiceClass implements IAbacService {
protected name = 'abac';
- private pdp!: IPolicyDecisionPoint;
+ private pdp: IPolicyDecisionPoint | null = null;
+
+ private virtruPdpConfig: VirtruPDPConfig = {
+ baseUrl: '',
+ clientId: '',
+ clientSecret: '',
+ oidcEndpoint: '',
+ defaultEntityKey: 'emailAddress',
+ attributeNamespace: 'example.com',
+ };
decisionCacheTimeout = 60; // seconds
constructor() {
super();
- this.setPdpStrategy('local');
+
+ this.onSettingChanged('ABAC_PDP_Type', async ({ setting }): Promise => {
+ const { value } = setting;
+ if (value !== 'local' && value !== 'virtru') {
+ return;
+ }
+
+ if (value === 'virtru') {
+ await this.loadVirtruPdpConfig();
+ }
+
+ this.setPdpStrategy(value);
+ });
this.onSettingChanged('Abac_Cache_Decision_Time_Seconds', async ({ setting }): Promise => {
const { value } = setting;
@@ -58,22 +84,98 @@ export class AbacService extends ServiceClass implements IAbacService {
}
this.decisionCacheTimeout = value;
});
+
+ this.onSettingChanged('ABAC_Virtru_Base_URL', async ({ setting }): Promise => {
+ this.virtruPdpConfig.baseUrl = stripTrailingSlashes(setting.value as string);
+ this.syncVirtruPdpConfig();
+ });
+
+ this.onSettingChanged('ABAC_Virtru_Client_ID', async ({ setting }): Promise => {
+ this.virtruPdpConfig.clientId = setting.value as string;
+ this.syncVirtruPdpConfig();
+ });
+
+ this.onSettingChanged('ABAC_Virtru_Client_Secret', async ({ setting }): Promise => {
+ this.virtruPdpConfig.clientSecret = setting.value as string;
+ this.syncVirtruPdpConfig();
+ });
+
+ this.onSettingChanged('ABAC_Virtru_OIDC_Endpoint', async ({ setting }): Promise => {
+ this.virtruPdpConfig.oidcEndpoint = stripTrailingSlashes(setting.value as string);
+ this.syncVirtruPdpConfig();
+ });
+
+ this.onSettingChanged('ABAC_Virtru_Default_Entity_Key', async ({ setting }): Promise => {
+ this.virtruPdpConfig.defaultEntityKey = setting.value as VirtruPDPConfig['defaultEntityKey'];
+ this.syncVirtruPdpConfig();
+ });
+
+ this.onSettingChanged('ABAC_Virtru_Attribute_Namespace', async ({ setting }): Promise => {
+ this.virtruPdpConfig.attributeNamespace = setting.value as string;
+ this.syncVirtruPdpConfig();
+ });
}
- setPdpStrategy(strategy: 'local' | 'external'): void {
+ private async loadVirtruPdpConfig(): Promise {
+ const [baseUrl, clientId, clientSecret, oidcEndpoint, defaultEntityKey, attributeNamespace] = await Promise.all([
+ Settings.get('ABAC_Virtru_Base_URL'),
+ Settings.get('ABAC_Virtru_Client_ID'),
+ Settings.get('ABAC_Virtru_Client_Secret'),
+ Settings.get('ABAC_Virtru_OIDC_Endpoint'),
+ Settings.get('ABAC_Virtru_Default_Entity_Key'),
+ Settings.get('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 {
+ if (this.pdp instanceof VirtruPDP) {
+ this.pdp.updateConfig({ ...this.virtruPdpConfig });
+ }
+ }
+
+ setPdpStrategy(strategy: 'local' | 'virtru'): void {
+ const previousPdp = this.pdp ? this.pdp.constructor.name : 'none';
+
switch (strategy) {
- case 'external':
- this.pdp = new ExternalPDP();
+ case 'virtru':
+ this.pdp = new VirtruPDP({ ...this.virtruPdpConfig });
+ 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 {
this.decisionCacheTimeout = await Settings.get('Abac_Cache_Decision_Time_Seconds');
+
+ const pdpType = await Settings.get('ABAC_PDP_Type');
+ if (pdpType !== 'virtru') {
+ this.setPdpStrategy('local');
+ return;
+ }
+
+ await this.loadVirtruPdpConfig();
+ this.setPdpStrategy('virtru');
}
async addSubjectAttributes(user: IUser, ldapUser: ILDAPEntry, map: Record): Promise {
@@ -327,6 +429,7 @@ export class AbacService extends ServiceClass implements IAbacService {
}
async setRoomAbacAttributes(rid: string, attributes: Record, actor: AbacActor): Promise {
+ await this.ensurePdpAvailable();
const room = await getAbacRoom(rid);
if (!Object.keys(attributes).length && room.abacAttributes?.length) {
@@ -349,6 +452,7 @@ export class AbacService extends ServiceClass implements IAbacService {
}
async updateRoomAbacAttributeValues(rid: string, key: string, values: string[], actor: AbacActor): Promise {
+ await this.ensurePdpAvailable();
const room = await getAbacRoom(rid);
const previous: IAbacAttributeDefinition[] = room.abacAttributes || [];
@@ -398,6 +502,7 @@ export class AbacService extends ServiceClass implements IAbacService {
}
async removeRoomAbacAttribute(rid: string, key: string, actor: AbacActor): Promise {
+ await this.ensurePdpAvailable();
const room = await getAbacRoom(rid);
const previous: IAbacAttributeDefinition[] = room.abacAttributes || [];
@@ -425,6 +530,7 @@ export class AbacService extends ServiceClass implements IAbacService {
}
async addRoomAbacAttributeByKey(rid: string, key: string, values: string[], actor: AbacActor): Promise {
+ await this.ensurePdpAvailable();
await ensureAttributeDefinitionsExist([{ key, values }]);
const room = await getAbacRoom(rid);
@@ -447,6 +553,7 @@ export class AbacService extends ServiceClass implements IAbacService {
}
async replaceRoomAbacAttributeByKey(rid: string, key: string, values: string[], actor: AbacActor): Promise {
+ await this.ensurePdpAvailable();
await ensureAttributeDefinitionsExist([{ key, values }]);
const room = await getAbacRoom(rid);
@@ -486,6 +593,14 @@ export class AbacService extends ServiceClass implements IAbacService {
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,
user: Pick,
@@ -505,26 +620,49 @@ export class AbacService extends ServiceClass implements IAbacService {
return false;
}
+ if (!this.pdp) {
+ return false;
+ }
+
const userSub = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { projection: { abacLastTimeChecked: 1 } });
if (!userSub) {
return false;
}
- const decision = await this.pdp.canAccessObject(room, user, userSub, this.decisionCacheTimeout);
+ 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) {
- // When a user is not compliant, remove them from the room automatically
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 {
- if (!usernames.length || !attributes.length) {
+ if (!usernames.length || !attributes.length || !this.pdp) {
return;
}
+ await this.ensurePdpAvailable();
await this.pdp.checkUsernamesMatchAttributes(usernames, attributes, object);
usernames.forEach((username) => {
@@ -532,12 +670,29 @@ export class AbacService extends ServiceClass implements IAbacService {
});
}
+ private pdpType: 'local' | 'virtru' = 'local';
+
+ private async ensurePdpAvailable(): Promise {
+ if (!(await this.pdp?.isAvailable())) {
+ throw new PdpUnavailableError();
+ }
+ }
+
private async removeUserFromRoom(room: AtLeast, user: IUser, reason: AbacAuditReason): Promise {
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))
+ .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',
@@ -563,6 +718,10 @@ export class AbacService extends ServiceClass implements IAbacService {
return;
}
+ if (!this.pdp) {
+ return;
+ }
+
try {
const nonCompliantUsers = await this.pdp.onRoomAttributesChanged(room, newAttributes);
@@ -581,7 +740,11 @@ export class AbacService extends ServiceClass implements IAbacService {
}
protected async onSubjectAttributesChanged(user: IUser, _next: IAbacAttributeDefinition[]): Promise {
- if (!user?._id || !Array.isArray(user.__rooms) || !user.__rooms.length) {
+ if (!user?._id || !Array.isArray(user.__rooms) || !user.__rooms.length || !this.pdp) {
+ return;
+ }
+
+ if (!(await this.pdp.isAvailable())) {
return;
}
@@ -600,9 +763,61 @@ export class AbacService extends ServiceClass implements IAbacService {
});
}
}
+
+ async getPDPHealth(): Promise {
+ if (!this.pdp) {
+ throw new PdpHealthCheckError('ABAC_PDP_Health_No_PDP');
+ }
+
+ await this.pdp.getHealthStatus();
+ }
+
+ async evaluateRoomMembership(): Promise {
+ 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 });
+ }
+ }
}
-export { LocalPDP, ExternalPDP } from './pdp';
-export type { IPolicyDecisionPoint } from './pdp';
+export { LocalPDP, VirtruPDP } from './pdp';
+export type { IPolicyDecisionPoint, VirtruPDPConfig } from './pdp';
+export { PdpHealthCheckError } from './errors';
export default AbacService;
diff --git a/ee/packages/abac/src/pdp/ExternalPDP.ts b/ee/packages/abac/src/pdp/ExternalPDP.ts
deleted file mode 100644
index 55ba1113ff3de..0000000000000
--- a/ee/packages/abac/src/pdp/ExternalPDP.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import type { IAbacAttributeDefinition, IRoom, IUser, AtLeast, ISubscription } from '@rocket.chat/core-typings';
-
-import type { IPolicyDecisionPoint } from './types';
-
-export class ExternalPDP implements IPolicyDecisionPoint {
- async canAccessObject(
- _room: AtLeast,
- _user: AtLeast,
- _userSub: ISubscription,
- _decisionCacheTimeout: number,
- ): Promise<{ granted: boolean; userToRemove?: IUser }> {
- throw new Error('ExternalPDP: canAccessObject not implemented');
- }
-
- async checkUsernamesMatchAttributes(_usernames: string[], _attributes: IAbacAttributeDefinition[], _object: IRoom): Promise {
- throw new Error('ExternalPDP: checkUsernamesMatchAttributes not implemented');
- }
-
- async onRoomAttributesChanged(
- _room: AtLeast,
- _newAttributes: IAbacAttributeDefinition[],
- ): Promise {
- throw new Error('ExternalPDP: onRoomAttributesChanged not implemented');
- }
-
- async onSubjectAttributesChanged(_user: IUser, _next: IAbacAttributeDefinition[]): Promise {
- throw new Error('ExternalPDP: onSubjectAttributesChanged not implemented');
- }
-}
diff --git a/ee/packages/abac/src/pdp/LocalPDP.ts b/ee/packages/abac/src/pdp/LocalPDP.ts
index d1ed31f909752..2b1d3ed51174e 100644
--- a/ee/packages/abac/src/pdp/LocalPDP.ts
+++ b/ee/packages/abac/src/pdp/LocalPDP.ts
@@ -1,38 +1,23 @@
-import type { IAbacAttributeDefinition, IRoom, AtLeast, IUser, ISubscription } from '@rocket.chat/core-typings';
-import { Rooms, Users, Subscriptions } from '@rocket.chat/models';
+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 { logger } from '../logger';
import type { IPolicyDecisionPoint } from './types';
-const pdpLogger = logger.section('LocalPDP');
-
export class LocalPDP implements IPolicyDecisionPoint {
- private shouldUseCache(decisionCacheTimeout: number, userSub: ISubscription) {
- // Cases:
- // 1) Never checked before -> check now
- // 2) Checked before, but cache expired -> check now
- // 3) Checked before, and cache valid -> use cached decision (subsciprtion exists)
- // 4) Cache disabled (0) -> always check
- return (
- decisionCacheTimeout > 0 &&
- userSub.abacLastTimeChecked &&
- Date.now() - userSub.abacLastTimeChecked.getTime() < decisionCacheTimeout * 1000
- );
+ async isAvailable(): Promise {
+ return true;
+ }
+
+ async getHealthStatus(): Promise {
+ // Local PDP is always available, nothing to check
}
async canAccessObject(
room: AtLeast,
user: AtLeast,
- userSub: ISubscription,
- decisionCacheTimeout: number,
): Promise<{ granted: boolean; userToRemove?: IUser }> {
- if (this.shouldUseCache(decisionCacheTimeout, userSub)) {
- pdpLogger.debug({ msg: 'Using cached ABAC decision', userId: user._id, roomId: room._id });
- return { granted: !!userSub };
- }
-
const isUserCompliant = await Users.findOne(
{
_id: user._id,
@@ -50,8 +35,6 @@ export class LocalPDP implements IPolicyDecisionPoint {
return { granted: false, userToRemove: fullUser };
}
- // Set last time the decision was made
- await Subscriptions.setAbacLastTimeCheckedByUserIdAndRoomId(user._id, room._id, new Date());
return { granted: true };
}
@@ -89,6 +72,15 @@ export class LocalPDP implements IPolicyDecisionPoint {
return Rooms.find(query, { projection: { _id: 1 } }).toArray();
}
+ async evaluateUserRooms(
+ _entries: Array<{
+ user: Pick;
+ rooms: AtLeast[];
+ }>,
+ ): Promise; room: IRoom }>> {
+ throw new Error('evaluateUserRooms is not implemented for LocalPDP');
+ }
+
async checkUsernamesMatchAttributes(usernames: string[], attributes: IAbacAttributeDefinition[], _object: IRoom): Promise {
const nonCompliantUsersFromList = await Users.find(
{
diff --git a/ee/packages/abac/src/pdp/VirtruPDP.ts b/ee/packages/abac/src/pdp/VirtruPDP.ts
new file mode 100644
index 0000000000000..3026214cb5ec8
--- /dev/null
+++ b/ee/packages/abac/src/pdp/VirtruPDP.ts
@@ -0,0 +1,510 @@
+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 pLimit from 'p-limit';
+
+import { OnlyCompliantCanBeAddedToRoomError, PdpHealthCheckError } from '../errors';
+import { logger } from '../logger';
+import type {
+ Decision,
+ IEntityIdentifier,
+ IPolicyDecisionPoint,
+ IGetDecisionRequest,
+ IGetDecisionBulkRequest,
+ IGetDecisionsResponse,
+ IGetDecisionBulkResponse,
+ IResourceDecision,
+ ITokenCache,
+ IVirtruPDPConfig,
+} from './types';
+
+const pdpLogger = logger.section('VirtruPDP');
+
+const HEALTH_CHECK_TIMEOUT = 5000;
+const REQUEST_TIMEOUT = 10000;
+
+export class VirtruPDP implements IPolicyDecisionPoint {
+ private tokenCache: ITokenCache | null = null;
+
+ private config: IVirtruPDPConfig;
+
+ constructor(config: IVirtruPDPConfig) {
+ this.config = config;
+ }
+
+ updateConfig(config: IVirtruPDPConfig): void {
+ this.config = config;
+ this.tokenCache = null;
+ }
+
+ async isAvailable(): Promise {
+ 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 };
+
+ pdpLogger.info({ msg: 'Virtru PDP health check response', data });
+ return data.status === 'SERVING';
+ } catch (err) {
+ pdpLogger.warn({ msg: 'Virtru PDP is not reachable', err });
+ return false;
+ }
+ }
+
+ async getHealthStatus(): Promise {
+ await this.checkPlatformHealth();
+ const token = await this.checkIdpConnectivity();
+ await this.checkAuthorizedAccess(token);
+ }
+
+ private async checkIdpConnectivity(): Promise {
+ try {
+ this.tokenCache = null;
+ return await this.getClientToken();
+ } catch {
+ throw new PdpHealthCheckError('ABAC_PDP_Health_IdP_Failed');
+ }
+ }
+
+ private async checkPlatformHealth(): Promise {
+ 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();
+ }
+
+ const data = (await response.json()) as { status?: string };
+ if (data.status !== 'SERVING') {
+ throw new Error();
+ }
+ } catch {
+ throw new PdpHealthCheckError('ABAC_PDP_Health_Platform_Failed');
+ }
+ }
+
+ private async checkAuthorizedAccess(token: string): Promise {
+ try {
+ const response = await serverFetch(`${this.config.baseUrl}/authorization.AuthorizationService/GetDecisions`, {
+ method: 'POST',
+ timeout: HEALTH_CHECK_TIMEOUT,
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
+ body: JSON.stringify({ decisionRequests: [] }),
+ // 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();
+ }
+ } catch {
+ throw new PdpHealthCheckError('ABAC_PDP_Health_Authorization_Failed');
+ }
+ }
+
+ private async getClientToken(): Promise {
+ 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,
+ }),
+ // 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(`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,
+ // We check for expiry 30 seconds before the actual expiry time for safety.
+ expiresAt: Date.now() + (expiresIn - 30) * 1000,
+ };
+
+ return data.access_token;
+ }
+
+ private async apiCall(endpoint: string, body: unknown): Promise {
+ const token = await this.getClientToken();
+
+ 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),
+ // SECURITY: This can only be configured by users with enough privileges. It's ok to disable this check here.
+ ignoreSsrfValidation: true,
+ });
+
+ if (!response.ok) {
+ const text = await response.text().catch(() => '');
+ pdpLogger.error({ msg: 'Virtru PDP API call failed', endpoint, status: response.status, response: text });
+ throw new Error('Virtru PDP call failed');
+ }
+
+ return response.json() as Promise;
+ }
+
+ private async getDecision(request: IGetDecisionRequest): Promise {
+ const result = await this.apiCall('/authorization.AuthorizationService/GetDecisions', {
+ decisionRequests: [request],
+ });
+
+ pdpLogger.debug({ msg: 'GetDecision response', result: result.decisionResponses });
+
+ return result.decisionResponses?.[0]?.decision;
+ }
+
+ private async getDecisionBulk(
+ requests: Array,
+ ): Promise> {
+ 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> => {
+ const validBatch = batch.filter(Boolean);
+
+ if (!validBatch.length) {
+ return batch.map(() => undefined);
+ }
+
+ const result = await this.apiCall('/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();
+ }
+
+ private buildAttributeFqns(attributes: IAbacAttributeDefinition[]): string[] {
+ if (!this.config.attributeNamespace) {
+ throw new Error('Attribute namespace is not configured for VirtruPDP');
+ }
+
+ return attributes.flatMap((attr) =>
+ attr.values.map((value) => `https://${this.config.attributeNamespace}/attr/${attr.key}/value/${value}`),
+ );
+ }
+
+ private buildEntityIdentifier(entityKey: string): IEntityIdentifier {
+ if (this.config.defaultEntityKey === 'emailAddress') {
+ return { emailAddress: entityKey };
+ }
+
+ return { id: entityKey };
+ }
+
+ private getUserEntityKey(user: Pick): string | undefined {
+ switch (this.config.defaultEntityKey) {
+ case 'emailAddress':
+ return user.emails?.[0]?.address;
+ case 'oidcIdentifier':
+ return user.username; // For now, username, we're gonna change this to find the right oidc identifier for the user
+ }
+ }
+
+ async canAccessObject(
+ room: AtLeast,
+ user: AtLeast,
+ ): Promise<{ granted: boolean; userToRemove?: IUser }> {
+ const attributes = room.abacAttributes ?? [];
+
+ if (!attributes.length) {
+ return { granted: true };
+ }
+
+ const fullUser = await Users.findOneById(user._id);
+ if (!fullUser) {
+ return { granted: false };
+ }
+
+ const entityKey = this.getUserEntityKey(fullUser);
+ if (!entityKey) {
+ pdpLogger.warn({ msg: 'User has no entity key for Virtru PDP evaluation', userId: user._id });
+ return { granted: false };
+ }
+
+ const decision = await this.getDecision({
+ actions: [{ standard: 1 }],
+ resourceAttributes: [
+ {
+ resourceAttributesId: room._id,
+ attributeValueFqns: this.buildAttributeFqns(attributes),
+ },
+ ],
+ entityChains: [
+ {
+ id: 'rc-access-check',
+ entities: [this.buildEntityIdentifier(entityKey)],
+ },
+ ],
+ });
+
+ if (decision === 'DECISION_PERMIT') {
+ return { granted: true };
+ }
+
+ if (decision === 'DECISION_DENY') {
+ return { granted: false, userToRemove: fullUser };
+ }
+
+ // If we get an inconclusive or error decision, we err on the side of caution and deny access, but do not remove the user since we can't be sure
+ return { granted: false };
+ }
+
+ async checkUsernamesMatchAttributes(usernames: string[], attributes: IAbacAttributeDefinition[], object: IRoom): Promise {
+ if (!usernames.length || !attributes.length) {
+ return;
+ }
+
+ const users = await Users.findByUsernames(usernames, { projection: { _id: 1, emails: 1, username: 1 } }).toArray();
+
+ const fqns = this.buildAttributeFqns(attributes);
+ const decisionRequests: IGetDecisionBulkRequest[] = [];
+
+ for (const user of users) {
+ const entityKey = this.getUserEntityKey(user);
+ if (!entityKey) {
+ throw new OnlyCompliantCanBeAddedToRoomError();
+ }
+
+ decisionRequests.push({
+ entityIdentifier: {
+ entityChain: {
+ entities: [this.buildEntityIdentifier(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,
+ newAttributes: IAbacAttributeDefinition[],
+ ): Promise {
+ if (!newAttributes.length) {
+ return [];
+ }
+
+ const users = Users.findActiveByRoomIds([room._id], {
+ projection: { _id: 1, emails: 1, username: 1 },
+ });
+
+ const nonCompliantUsers: IUser[] = [];
+ const decisionRequests: IGetDecisionBulkRequest[] = [];
+ const requestUserIndex: IUser[] = [];
+ const fqns = this.buildAttributeFqns(newAttributes);
+
+ for await (const user of users) {
+ const entityKey = this.getUserEntityKey(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;
+ }
+
+ requestUserIndex.push(user);
+ decisionRequests.push({
+ entityIdentifier: {
+ entityChain: {
+ entities: [this.buildEntityIdentifier(entityKey)],
+ },
+ },
+ action: { name: 'read' },
+ resources: [
+ {
+ ephemeralId: room._id,
+ attributeValues: { fqns },
+ },
+ ],
+ });
+ }
+
+ if (!decisionRequests.length) {
+ return nonCompliantUsers;
+ }
+
+ const responses = await this.getDecisionBulk(decisionRequests);
+
+ responses.forEach((resp, index) => {
+ const permitted = resp?.resourceDecisions?.length && resp.resourceDecisions.every((rd) => rd.decision === 'DECISION_PERMIT');
+ if (!permitted && requestUserIndex[index]) {
+ nonCompliantUsers.push(requestUserIndex[index]);
+ }
+ });
+
+ return nonCompliantUsers;
+ }
+
+ async evaluateUserRooms(
+ entries: Array<{
+ user: Pick;
+ rooms: AtLeast[];
+ }>,
+ ): Promise; room: IRoom }>> {
+ const requestIndex: Array<{ user: Pick; room: AtLeast }> = [];
+ const allRequests: IGetDecisionBulkRequest[] = [];
+
+ const nonCompliant: Array<{ user: Pick; room: IRoom }> = [];
+
+ for (const { user, rooms } of entries) {
+ const entityKey = this.getUserEntityKey(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: room as IRoom });
+ }
+ continue;
+ }
+
+ for (const room of rooms) {
+ requestIndex.push({ user, room });
+ allRequests.push({
+ entityIdentifier: {
+ entityChain: {
+ entities: [this.buildEntityIdentifier(entityKey)],
+ },
+ },
+ action: { name: 'read' },
+ resources: [
+ {
+ ephemeralId: room._id,
+ attributeValues: { fqns: this.buildAttributeFqns(room.abacAttributes ?? []) },
+ },
+ ],
+ });
+ }
+ }
+
+ if (!allRequests.length) {
+ return nonCompliant;
+ }
+
+ const responses = await this.getDecisionBulk(allRequests);
+
+ responses.forEach((resp, index) => {
+ const permitted = resp?.resourceDecisions?.length && resp.resourceDecisions.every((rd) => rd.decision === 'DECISION_PERMIT');
+ if (!permitted && requestIndex[index]) {
+ nonCompliant.push({ user: requestIndex[index].user, room: requestIndex[index].room as IRoom });
+ }
+ });
+
+ return nonCompliant;
+ }
+
+ async onSubjectAttributesChanged(user: IUser, _next: IAbacAttributeDefinition[]): Promise {
+ 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 entityKey = this.getUserEntityKey(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 as IRoom[];
+ }
+
+ const decisionRequests = abacRooms.map((room) => ({
+ entityIdentifier: {
+ entityChain: {
+ entities: [this.buildEntityIdentifier(entityKey)],
+ },
+ },
+ action: { name: 'read' },
+ resources: [
+ {
+ ephemeralId: room._id,
+ attributeValues: { fqns: this.buildAttributeFqns(room.abacAttributes ?? []) },
+ },
+ ],
+ }));
+
+ const responses = await this.getDecisionBulk(decisionRequests);
+
+ const nonCompliantRooms: IRoom[] = [];
+
+ responses.forEach((resp, index) => {
+ const permitted = resp?.resourceDecisions?.length && resp.resourceDecisions.every((rd) => rd.decision === 'DECISION_PERMIT');
+ if (!permitted && abacRooms[index]) {
+ nonCompliantRooms.push(abacRooms[index]);
+ }
+ });
+
+ return nonCompliantRooms;
+ }
+}
diff --git a/ee/packages/abac/src/pdp/index.ts b/ee/packages/abac/src/pdp/index.ts
index 210941a411dbc..7cd6ddd06652a 100644
--- a/ee/packages/abac/src/pdp/index.ts
+++ b/ee/packages/abac/src/pdp/index.ts
@@ -1,3 +1,3 @@
export { LocalPDP } from './LocalPDP';
-export { ExternalPDP } from './ExternalPDP';
-export type { IPolicyDecisionPoint } from './types';
+export { VirtruPDP } from './VirtruPDP';
+export type { IVirtruPDPConfig as VirtruPDPConfig, IPolicyDecisionPoint } from './types';
diff --git a/ee/packages/abac/src/pdp/types.ts b/ee/packages/abac/src/pdp/types.ts
index 27ea14fa51c74..22a8d4b855c4e 100644
--- a/ee/packages/abac/src/pdp/types.ts
+++ b/ee/packages/abac/src/pdp/types.ts
@@ -1,11 +1,59 @@
-import type { IAbacAttributeDefinition, IRoom, IUser, AtLeast, ISubscription } from '@rocket.chat/core-typings';
+import type { IAbacAttributeDefinition, IRoom, IUser, AtLeast } from '@rocket.chat/core-typings';
+
+export type IEntityIdentifier = { emailAddress: string } | { id: string };
+
+export interface IGetDecisionRequest {
+ actions: Array<{ standard: number }>;
+ resourceAttributes: Array<{
+ resourceAttributesId: string;
+ attributeValueFqns: string[];
+ }>;
+ entityChains: Array<{
+ id: string;
+ entities: IEntityIdentifier[];
+ }>;
+}
+
+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 IGetDecisionsResponse {
+ decisionResponses?: Array<{
+ decision?: Decision;
+ }>;
+}
+
+export interface IGetDecisionBulkResponse {
+ decisionResponses?: Array<{
+ resourceDecisions?: IResourceDecision[];
+ }>;
+}
export interface IPolicyDecisionPoint {
+ isAvailable(): Promise;
+
+ getHealthStatus(): Promise;
+
canAccessObject(
room: AtLeast,
user: AtLeast,
- userSub: ISubscription,
- decisionCacheTimeout: number,
): Promise<{ granted: boolean; userToRemove?: IUser }>;
checkUsernamesMatchAttributes(usernames: string[], attributes: IAbacAttributeDefinition[], object: IRoom): Promise;
@@ -16,4 +64,25 @@ export interface IPolicyDecisionPoint {
): Promise;
onSubjectAttributesChanged(user: IUser, next: IAbacAttributeDefinition[]): Promise;
+
+ evaluateUserRooms(
+ entries: Array<{
+ user: Pick;
+ rooms: AtLeast[];
+ }>,
+ ): Promise; room: IRoom }>>;
+}
+
+export interface IVirtruPDPConfig {
+ baseUrl: string;
+ clientId: string;
+ clientSecret: string;
+ oidcEndpoint: string;
+ defaultEntityKey: 'emailAddress' | 'oidcIdentifier';
+ attributeNamespace: string;
+}
+
+export interface ITokenCache {
+ accessToken: string;
+ expiresAt: number;
}
diff --git a/ee/packages/abac/src/service.spec.ts b/ee/packages/abac/src/service.spec.ts
index d1de750b3c119..0402b7523a0c7 100644
--- a/ee/packages/abac/src/service.spec.ts
+++ b/ee/packages/abac/src/service.spec.ts
@@ -81,6 +81,7 @@ describe('AbacService (unit)', () => {
beforeEach(() => {
service = new AbacService();
+ service.setPdpStrategy('local');
jest.clearAllMocks();
});
diff --git a/ee/packages/abac/src/subject-attributes-validations.spec.ts b/ee/packages/abac/src/subject-attributes-validations.spec.ts
index d76dd3985857c..88744bff7174e 100644
--- a/ee/packages/abac/src/subject-attributes-validations.spec.ts
+++ b/ee/packages/abac/src/subject-attributes-validations.spec.ts
@@ -89,6 +89,7 @@ const getStaticUser = (_id: string, overrides: Partial = {}): IUser => {
type StaticUserUpdate = Partial & { _id: string };
const service = new AbacService();
+service.setPdpStrategy('local');
let db: Db;
let sharedMongo: SharedMongoConnection;
diff --git a/ee/packages/abac/src/user-auto-removal.spec.ts b/ee/packages/abac/src/user-auto-removal.spec.ts
index 5c26a50ef248e..29d234b49fc47 100644
--- a/ee/packages/abac/src/user-auto-removal.spec.ts
+++ b/ee/packages/abac/src/user-auto-removal.spec.ts
@@ -23,6 +23,7 @@ describe('AbacService integration (onRoomAttributesChanged)', () => {
let sharedMongo: SharedMongoConnection;
let db: Db;
const service = new AbacService();
+ service.setPdpStrategy('local');
let roomsCol: Collection;
let usersCol: Collection;
diff --git a/packages/core-services/src/types/IAbacService.ts b/packages/core-services/src/types/IAbacService.ts
index e7f19e0ad1fd2..17e9a19fe93bf 100644
--- a/packages/core-services/src/types/IAbacService.ts
+++ b/packages/core-services/src/types/IAbacService.ts
@@ -46,4 +46,6 @@ export interface IAbacService {
objectType: AbacObjectType,
): Promise;
addSubjectAttributes(user: IUser, ldapUser: ILDAPEntry, map: Record, actor: AbacActor | undefined): Promise;
+ evaluateRoomMembership(): Promise;
+ getPDPHealth(): Promise;
}
diff --git a/packages/core-typings/src/ISetting.ts b/packages/core-typings/src/ISetting.ts
index 85b739fe04e9e..f9340d85ef9a5 100644
--- a/packages/core-typings/src/ISetting.ts
+++ b/packages/core-typings/src/ISetting.ts
@@ -18,6 +18,7 @@ export type SettingValue =
| SettingValueAction
| Date
| { url?: string; defaultUrl?: string }
+ | { method: 'GET' | 'POST' | 'PUT' | 'DELETE'; path: string }
| undefined
| null;
diff --git a/packages/core-typings/src/ServerAudit/IAuditServerAbacAction.ts b/packages/core-typings/src/ServerAudit/IAuditServerAbacAction.ts
index 720e1c4d53c38..f044a1c375de6 100644
--- a/packages/core-typings/src/ServerAudit/IAuditServerAbacAction.ts
+++ b/packages/core-typings/src/ServerAudit/IAuditServerAbacAction.ts
@@ -3,7 +3,9 @@ import type { IUser, IRoom, IAuditServerEventType, IAbacAttributeDefinition, ISe
export type MinimalUser = Pick & Optional, '_id'>;
export type MinimalRoom = Pick;
-export type AbacAuditReason = 'ldap-sync' | 'room-attributes-change' | 'system' | 'api' | 'realtime-policy-eval';
+export type AbacAuditReason = 'ldap-sync' | 'room-attributes-change' | 'system' | 'api' | 'realtime-policy-eval' | 'virtru-pdp-sync';
+
+export type AbacPdpType = 'local' | 'virtru';
export type AbacActionPerformed = 'revoked-object-access' | 'granted-object-access';
@@ -54,6 +56,7 @@ export interface IServerEventAbacActionPerformed
| { key: 'reason'; value: AbacAuditReason }
| { key: 'subject'; value: MinimalUser | undefined }
| { key: 'object'; value: MinimalRoom | undefined }
+ | { key: 'pdp'; value: AbacPdpType | undefined }
> {
t: 'abac.action.performed';
}
diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json
index c9890acec8662..47329cf6f7e22 100644
--- a/packages/i18n/src/locales/en.i18n.json
+++ b/packages/i18n/src/locales/en.i18n.json
@@ -12,10 +12,36 @@
"@username": "@username",
"@username_message": "@username ",
"ABAC": "Attribute Based Access Control",
+ "ABAC_PDP_Health_OK": "All systems operational",
+ "ABAC_PDP_Health_No_PDP": "No PDP configured",
+ "ABAC_PDP_Health_IdP_Failed": "Unable to connect to the Identity Provider (IdP) or generate a token",
+ "ABAC_PDP_Health_Platform_Failed": "Unable to reach the Virtru platform health endpoint",
+ "ABAC_PDP_Health_Authorization_Failed": "Unable to perform an authenticated request to the Virtru platform. OIDC Client may not have admin rights.",
+ "ABAC_PDP_Health_Not_OK": "PDP health check failed due to an unexpected error",
+ "ABAC_Virtru_Test_Connection": "Test Connection",
+ "ABAC_Virtru_Test_Connection_Action": "Test connection",
"ABAC_Enabled": "Enable Attribute Based Access Control (ABAC)",
"ABAC_Enabled_Description": "Controls access to rooms based on user and room attributes.",
+ "ABAC_PDP_Type": "Policy Decision Point (PDP)",
+ "ABAC_PDP_Type_Description": "Select the Policy Decision Point engine to use for access control decisions.",
+ "ABAC_PDP_Type_Local": "Local",
+ "ABAC_PDP_Type_Virtru": "Virtru",
"Abac_Cache_Decision_Time_Seconds": "ABAC Cache Decision Time (seconds)",
"Abac_Cache_Decision_Time_Seconds_Description": "Time in seconds to cache access control decisions. Setting this value to 0 will disable caching.",
+ "ABAC_Virtru_PDP_Configuration": "Virtru PDP Configuration",
+ "ABAC_Virtru_Base_URL": "Base URL",
+ "ABAC_Virtru_Client_ID": "Client ID",
+ "ABAC_Virtru_Client_Secret": "Client Secret",
+ "ABAC_Virtru_OIDC_Endpoint": "OIDC Endpoint",
+ "ABAC_Virtru_OIDC_Endpoint_Description": "Base OIDC realm URL used for client credentials authentication (e.g. https://keycloak.example.com/auth/realms/opentdf).",
+ "ABAC_Virtru_Default_Entity_Key": "Default Entity Key",
+ "ABAC_Virtru_Default_Entity_Key_Description": "The `entity` identifier that will be sent to the ERS.",
+ "ABAC_Virtru_Entity_Key_Email": "Email Address",
+ "ABAC_Virtru_Entity_Key_OIDC": "OIDC Identifier",
+ "ABAC_Virtru_Attribute_Namespace": "Attribute Namespace",
+ "ABAC_Virtru_Attribute_Namespace_Description": "The namespace used to build attribute FQNs (e.g. opentdf.io produces https://opentdf.io/attr/{key}/value/{value}).",
+ "ABAC_Virtru_Sync_Interval": "Membership Sync Interval",
+ "ABAC_Virtru_Sync_Interval_Description": "Cron expression for how often to re-evaluate all ABAC room memberships against the Virtru PDP and evict non-compliant users (e.g. */5 * * * * for every 5 minutes).",
"ABAC_Enabled_callout": "User attributes are synchronized via LDAP. <1>Learn more1>",
"ABAC_Learn_More": "Learn about ABAC",
"ABAC_automatically_disabled_callout": "ABAC automatically disabled",
diff --git a/packages/model-typings/src/models/IUsersModel.ts b/packages/model-typings/src/models/IUsersModel.ts
index 383db7efaf337..d3c860a86ee5d 100644
--- a/packages/model-typings/src/models/IUsersModel.ts
+++ b/packages/model-typings/src/models/IUsersModel.ts
@@ -9,6 +9,7 @@ import type {
AtLeast,
ILivechatAgentStatus,
IMeteorLoginToken,
+ IRoom,
} from '@rocket.chat/core-typings';
import type {
Document,
@@ -165,6 +166,7 @@ export interface IUsersModel extends IBaseModel {
setAbacAttributesById(userId: IUser['_id'], attributes: NonNullable): Promise;
unsetAbacAttributesById(userId: IUser['_id']): Promise;
+ findActiveByRoomIds(roomIds: IRoom['_id'][], options?: FindOptions): FindCursor;
updateStatusText(_id: IUser['_id'], statusText: string, options?: UpdateOptions): Promise;
diff --git a/packages/models/src/models/Users.ts b/packages/models/src/models/Users.ts
index 95cc31246380a..ac85896d4ab37 100644
--- a/packages/models/src/models/Users.ts
+++ b/packages/models/src/models/Users.ts
@@ -143,6 +143,10 @@ export class UsersRaw extends BaseRaw> implements IU
return this.findOneAndUpdate({ _id }, { $unset: { abacAttributes: 1 } }, { returnDocument: 'after' });
}
+ findActiveByRoomIds(roomIds: IRoom['_id'][], options?: FindOptions) {
+ return this.find({ active: true, __rooms: { $in: roomIds } }, options);
+ }
+
/**
* @param {string} uid
* @param {IRole['_id'][]} roles list of role ids
diff --git a/packages/server-fetch/tsconfig.json b/packages/server-fetch/tsconfig.json
index ab796f82165b7..13f56195c6347 100644
--- a/packages/server-fetch/tsconfig.json
+++ b/packages/server-fetch/tsconfig.json
@@ -1,7 +1,6 @@
{
"extends": "@rocket.chat/tsconfig/server.json",
"compilerOptions": {
- "module": "esnext",
"rootDir": ".",
"outDir": "./dist",
"declaration": true,
diff --git a/yarn.lock b/yarn.lock
index 5aeda2ff0e369..d1a14bcc23c75 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8957,6 +8957,8 @@ __metadata:
"@rocket.chat/core-typings": "workspace:^"
"@rocket.chat/logger": "workspace:^"
"@rocket.chat/models": "workspace:^"
+ "@rocket.chat/server-fetch": "workspace:^"
+ "@rocket.chat/tools": "workspace:^"
"@rocket.chat/tsconfig": "workspace:*"
"@types/jest": "npm:~30.0.0"
"@types/node": "npm:~22.16.5"