From 9085f890dafc88a895a9b56eac7ea99835ffcb9e Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 23 Mar 2026 11:56:32 -0600 Subject: [PATCH 01/41] poc - external decision delegation --- .../ABAC/ABACSettingTab/SettingsPage.tsx | 35 +- apps/meteor/ee/server/configuration/abac.ts | 49 +- apps/meteor/ee/server/settings/abac.ts | 81 +++- ee/packages/abac/package.json | 1 + ee/packages/abac/src/index.ts | 82 ++++ ee/packages/abac/src/pdp/ExternalPDP.ts | 448 +++++++++++++++++- ee/packages/abac/src/pdp/LocalPDP.ts | 32 ++ ee/packages/abac/src/pdp/types.ts | 5 + .../core-services/src/types/IAbacService.ts | 1 + .../src/ServerAudit/IAuditServerAbacAction.ts | 2 +- packages/i18n/src/locales/en.i18n.json | 18 + 11 files changed, 732 insertions(+), 22 deletions(-) diff --git a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx index 016b3c128e4ce..70f24d9dac9cb 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 + + + + )} + @@ -26,14 +41,18 @@ const SettingsPage = () => { - - - User attributes are synchronized via LDAP - - Learn more - - - + + + + + + + + + + + + ); diff --git a/apps/meteor/ee/server/configuration/abac.ts b/apps/meteor/ee/server/configuration/abac.ts index b2f0c1aa3ab80..c91dd52575b4f 100644 --- a/apps/meteor/ee/server/configuration/abac.ts +++ b/apps/meteor/ee/server/configuration/abac.ts @@ -1,3 +1,5 @@ +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 { Meteor } from 'meteor/meteor'; @@ -5,8 +7,19 @@ import { Meteor } from 'meteor/meteor'; import { settings } from '../../../app/settings/server'; import { LDAPEE } from '../sdk'; +const EXTERNAL_PDP_SYNC_JOB = 'ABAC_External_PDP_Sync'; + +const intervalToCronMap: Record = { + every_1_hour: '0 * * * *', + every_6_hours: '0 */6 * * *', + every_12_hours: '0 */12 * * *', + every_24_hours: '0 0 * * *', +}; + Meteor.startup(async () => { let stopWatcher: () => void; + let stopCronWatcher: () => void; + License.onToggledFeature('abac', { up: async () => { const { addSettings } = await import('../settings/abac'); @@ -22,9 +35,43 @@ Meteor.startup(async () => { await LDAPEE.syncUsersAbacAttributes(Users.findLDAPUsers()); } }); + + // External PDP sync cron + let lastSchedule: string; + async function configureExternalPdpSync(): Promise { + const abacEnabled = settings.get('ABAC_Enabled'); + const pdpType = settings.get('ABAC_PDP_Type'); + + if (!abacEnabled || pdpType !== 'virtru') { + if (await cronJobs.has(EXTERNAL_PDP_SYNC_JOB)) { + await cronJobs.remove(EXTERNAL_PDP_SYNC_JOB); + } + return; + } + + const intervalValue = settings.get('ABAC_Virtru_Sync_Interval') || 'every_24_hours'; + const schedule = intervalToCronMap[intervalValue] ?? intervalToCronMap.every_24_hours; + + if (schedule !== lastSchedule && (await cronJobs.has(EXTERNAL_PDP_SYNC_JOB))) { + await cronJobs.remove(EXTERNAL_PDP_SYNC_JOB); + } + + lastSchedule = schedule; + await cronJobs.add(EXTERNAL_PDP_SYNC_JOB, schedule, () => Abac.syncExternalPdpRooms()); + } + + stopCronWatcher = settings.watchMultiple( + ['ABAC_Enabled', 'ABAC_PDP_Type', 'ABAC_Virtru_Sync_Interval'], + () => configureExternalPdpSync(), + ); }, - down: () => { + down: async () => { stopWatcher?.(); + stopCronWatcher?.(); + + if (await cronJobs.has(EXTERNAL_PDP_SYNC_JOB)) { + await cronJobs.remove(EXTERNAL_PDP_SYNC_JOB); + } }, }); }); diff --git a/apps/meteor/ee/server/settings/abac.ts b/apps/meteor/ee/server/settings/abac.ts index 54b93912ddb7c..93c6e935cf5a9 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,19 +18,93 @@ 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, { _id: 'ABAC_PDP_Type', value: 'local' }], + }); + + // 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', + 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', 'every_24_hours', { + type: 'select', + public: false, + invalidValue: 'every_24_hours', + section: 'ABAC_Virtru_PDP_Configuration', + values: [ + { key: 'every_1_hour', i18nLabel: 'every_hour' }, + { key: 'every_6_hours', i18nLabel: 'every_six_hours' }, + { key: 'every_12_hours', i18nLabel: 'every_12_hours' }, + { key: 'every_24_hours', i18nLabel: 'every_24_hours' }, + ], + enableQuery: virtruPdpQuery, }); }, ); diff --git a/ee/packages/abac/package.json b/ee/packages/abac/package.json index 026e965a4b886..f2775ad092763 100644 --- a/ee/packages/abac/package.json +++ b/ee/packages/abac/package.json @@ -31,6 +31,7 @@ "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/logger": "workspace:^", "@rocket.chat/models": "workspace:^", + "@rocket.chat/server-fetch": "workspace:^", "mem": "^8.1.1", "mongodb": "6.10.0", "p-limit": "3.1.0" diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index a60df806e782a..829cc50fa7b95 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -51,6 +51,15 @@ export class AbacService extends ServiceClass implements IAbacService { super(); this.setPdpStrategy('local'); + this.onSettingChanged('ABAC_PDP_Type', async ({ setting }): Promise => { + const { value } = setting; + if (value === 'local') { + this.setPdpStrategy('local'); + } else if (value === 'virtru') { + this.setPdpStrategy('external'); + } + }); + this.onSettingChanged('Abac_Cache_Decision_Time_Seconds', async ({ setting }): Promise => { const { value } = setting; if (typeof value !== 'number') { @@ -74,6 +83,11 @@ export class AbacService extends ServiceClass implements IAbacService { 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('external'); + } } async addSubjectAttributes(user: IUser, ldapUser: ILDAPEntry, map: Record): Promise { @@ -600,6 +614,74 @@ export class AbacService extends ServiceClass implements IAbacService { }); } } + + async syncExternalPdpRooms(): Promise { + logger.info('Starting external PDP room membership sync'); + + // Build a map of ABAC rooms for quick lookup + const abacRoomsMap = new Map(); + const abacRoomsCursor = Rooms.find( + { abacAttributes: { $exists: true, $ne: [] } }, + { projection: { _id: 1, t: 1, teamMain: 1, abacAttributes: 1 } }, + ); + + for await (const room of abacRoomsCursor) { + abacRoomsMap.set(room._id, room); + } + + if (!abacRoomsMap.size) { + logger.info('No ABAC-managed rooms found, skipping sync'); + return; + } + + const abacRoomIds = Array.from(abacRoomsMap.keys()); + + // Get all active users that are members of at least one ABAC room + const usersCursor = Users.find( + { + active: true, + __rooms: { $in: abacRoomIds }, + }, + { projection: { _id: 1, emails: 1, username: 1, __rooms: 1 } }, + ); + + let userCount = 0; + let evictedCount = 0; + + for await (const user of usersCursor) { + // Use __rooms to resolve the user's ABAC rooms directly from the map — no extra DB query + const userAbacRooms = (user.__rooms ?? []) + .map((rid) => abacRoomsMap.get(rid)) + .filter((room): room is IRoom => !!room); + + if (!userAbacRooms.length) { + continue; + } + + try { + const nonCompliantRooms = await this.pdp.evaluateUserRooms(user, userAbacRooms); + + if (!nonCompliantRooms.length) { + userCount++; + continue; + } + + await Promise.all( + nonCompliantRooms.map((room) => limit(() => this.removeUserFromRoom(room, user as IUser, 'external-pdp-sync'))), + ); + evictedCount += nonCompliantRooms.length; + userCount++; + } catch (err) { + logger.error({ + msg: 'External PDP sync failed for user', + userId: user._id, + err, + }); + } + } + + logger.info({ msg: 'External PDP room membership sync completed', userCount, evictedCount }); + } } export { LocalPDP, ExternalPDP } from './pdp'; diff --git a/ee/packages/abac/src/pdp/ExternalPDP.ts b/ee/packages/abac/src/pdp/ExternalPDP.ts index 55ba1113ff3de..1844ba9ccc8ef 100644 --- a/ee/packages/abac/src/pdp/ExternalPDP.ts +++ b/ee/packages/abac/src/pdp/ExternalPDP.ts @@ -1,29 +1,457 @@ +import { Settings } from '@rocket.chat/core-services'; import type { IAbacAttributeDefinition, IRoom, IUser, AtLeast, ISubscription } from '@rocket.chat/core-typings'; +import { Rooms, Users, Subscriptions } from '@rocket.chat/models'; +import { serverFetch } from '@rocket.chat/server-fetch'; +import { OnlyCompliantCanBeAddedToRoomError } from '../errors'; +import { logger } from '../logger'; import type { IPolicyDecisionPoint } from './types'; +const pdpLogger = logger.section('ExternalPDP'); + +interface VirtruConfig { + baseUrl: string; + clientId: string; + clientSecret: string; + oidcEndpoint: string; + defaultEntityKey: string; + attributeNamespace: string; +} + +interface TokenCache { + accessToken: string; + expiresAt: number; +} + export class ExternalPDP implements IPolicyDecisionPoint { + private tokenCache: TokenCache | null = null; + + private async getConfig(): 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'), + ]); + + return { baseUrl, clientId, clientSecret, oidcEndpoint, defaultEntityKey, attributeNamespace: attributeNamespace || 'example.com' }; + } + + private async getClientToken(config: VirtruConfig): Promise { + if (this.tokenCache && Date.now() < this.tokenCache.expiresAt) { + return this.tokenCache.accessToken; + } + + const response = await serverFetch( + `${config.oidcEndpoint}/protocol/openid-connect/token`, + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: config.clientId, + client_secret: config.clientSecret, + }), + ignoreSsrfValidation: true, + }, + 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 }; + + // Cache token with a safety margin of 30 seconds + const expiresIn = data.expires_in ?? 300; + this.tokenCache = { + accessToken: data.access_token, + expiresAt: Date.now() + (expiresIn - 30) * 1000, + }; + + return data.access_token; + } + + private async apiCall(config: VirtruConfig, endpoint: string, body: unknown): Promise { + const token = await this.getClientToken(config); + + const response = await serverFetch( + `${config.baseUrl}${endpoint}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(body), + ignoreSsrfValidation: true, + }, + true, + ); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`Virtru API call to ${endpoint} failed: ${response.status} ${response.statusText} - ${text}`); + } + + return response.json() as Promise; + } + + private buildAttributeFqns(attributes: IAbacAttributeDefinition[], namespace: string): string[] { + if (!namespace) { + throw new Error('ExternalPDP: attribute namespace is not configured'); + } + + return attributes.flatMap((attr) => attr.values.map((value) => `https://${namespace}/attr/${attr.key}/value/${value}`)); + } + + private buildEntityIdentifier(entityKey: string, defaultEntityKey: string) { + if (defaultEntityKey === 'emailAddress') { + return { emailAddress: entityKey }; + } + + return { id: entityKey }; + } + + private getUserEntityKey(user: Pick, defaultEntityKey: string): string | undefined { + if (!defaultEntityKey) { + throw new Error('ExternalPDP: default entity key is not configured'); + } + + switch (defaultEntityKey) { + case 'emailAddress': + return user.emails?.[0]?.address; + case 'oidcIdentifier': + return user.username; + default: + throw new Error(`ExternalPDP: unknown entity key type: ${defaultEntityKey}`); + } + } + async canAccessObject( - _room: AtLeast, - _user: AtLeast, + room: AtLeast, + user: AtLeast, _userSub: ISubscription, _decisionCacheTimeout: number, ): Promise<{ granted: boolean; userToRemove?: IUser }> { - throw new Error('ExternalPDP: canAccessObject not implemented'); + const config = await this.getConfig(); + const attributes = room.abacAttributes ?? []; + + if (!attributes.length) { + return { granted: true }; + } + + const fullUser = await Users.findOneById(user._id, { projection: { _id: 1, emails: 1, username: 1 } }); + if (!fullUser) { + return { granted: false }; + } + + const entityKey = this.getUserEntityKey(fullUser, config.defaultEntityKey); + if (!entityKey) { + pdpLogger.warn({ msg: 'User has no entity key for external PDP evaluation', userId: user._id }); + return { granted: false }; + } + + const fqns = this.buildAttributeFqns(attributes, config.attributeNamespace); + + const result = await this.apiCall<{ + decisionResponses?: Array<{ + decision?: string; + }>; + }>(config, '/authorization.AuthorizationService/GetDecisions', { + decisionRequests: [ + { + actions: [{ standard: 1 }], + resourceAttributes: [ + { + resourceAttributesId: room._id, + attributeValueFqns: fqns, + }, + ], + entityChains: [ + { + id: 'rc-access-check', + entities: [this.buildEntityIdentifier(entityKey, config.defaultEntityKey)], + }, + ], + }, + ], + }); + + const decision = result.decisionResponses?.[0]?.decision; + pdpLogger.debug({ msg: 'GetDecisions response', userId: user._id, roomId: room._id, decision, fqns }); + + const granted = decision === 'DECISION_PERMIT'; + + if (!granted) { + const userToRemove = await Users.findOneById(user._id); + if (userToRemove) { + return { granted: false, userToRemove }; + } + } + + return { granted }; } - async checkUsernamesMatchAttributes(_usernames: string[], _attributes: IAbacAttributeDefinition[], _object: IRoom): Promise { - throw new Error('ExternalPDP: checkUsernamesMatchAttributes not implemented'); + async checkUsernamesMatchAttributes(usernames: string[], attributes: IAbacAttributeDefinition[], object: IRoom): Promise { + if (!usernames.length || !attributes.length) { + return; + } + + const config = await this.getConfig(); + const fqns = this.buildAttributeFqns(attributes, config.attributeNamespace); + + const users = await Users.find({ username: { $in: usernames } }, { projection: { _id: 1, emails: 1, username: 1 } }).toArray(); + + const decisionRequests = users + .map((user) => { + const entityKey = this.getUserEntityKey(user, config.defaultEntityKey); + if (!entityKey) { + return null; + } + + return { + entityIdentifier: { + entityChain: { + entities: [this.buildEntityIdentifier(entityKey, config.defaultEntityKey)], + }, + }, + action: { name: 'read' }, + resources: [ + { + ephemeralId: object._id, + attributeValues: { fqns }, + }, + ], + }; + }) + .filter(Boolean); + + if (!decisionRequests.length) { + throw new OnlyCompliantCanBeAddedToRoomError(); + } + + const result = await this.apiCall<{ + decisionResponses?: Array<{ + resourceDecisions?: Array<{ + decision?: string; + }>; + }>; + }>(config, '/authorization.v2.AuthorizationService/GetDecisionBulk', { + decisionRequests, + }); + + pdpLogger.debug({ msg: 'GetDecisionBulk response (checkUsernames)', roomId: object._id, result: result.decisionResponses }); + + const hasNonCompliant = result.decisionResponses?.some((resp) => + resp.resourceDecisions?.some((rd) => rd.decision !== 'DECISION_PERMIT'), + ); + + if (hasNonCompliant) { + throw new OnlyCompliantCanBeAddedToRoomError(); + } } async onRoomAttributesChanged( - _room: AtLeast, - _newAttributes: IAbacAttributeDefinition[], + room: AtLeast, + newAttributes: IAbacAttributeDefinition[], ): Promise { - throw new Error('ExternalPDP: onRoomAttributesChanged not implemented'); + if (!newAttributes.length) { + return []; + } + + const config = await this.getConfig(); + const fqns = this.buildAttributeFqns(newAttributes, config.attributeNamespace); + + const subscriptions = await Subscriptions.findByRoomId(room._id, { projection: { 'u._id': 1 } }).toArray(); + const userIds = subscriptions.map((s) => s.u._id); + + if (!userIds.length) { + return []; + } + + const users = await Users.find({ _id: { $in: userIds } }, { projection: { _id: 1, emails: 1, username: 1, __rooms: 1 } }).toArray(); + + const usersWithKeys = users + .map((user) => ({ + user, + entityKey: this.getUserEntityKey(user, config.defaultEntityKey), + })) + .filter((entry): entry is { user: IUser; entityKey: string } => !!entry.entityKey); + + if (!usersWithKeys.length) { + return users as IUser[]; + } + + const decisionRequests = usersWithKeys.map(({ entityKey }) => ({ + entityIdentifier: { + entityChain: { + entities: [this.buildEntityIdentifier(entityKey, config.defaultEntityKey)], + }, + }, + action: { name: 'read' }, + resources: [ + { + ephemeralId: room._id, + attributeValues: { fqns }, + }, + ], + })); + + const result = await this.apiCall<{ + decisionResponses?: Array<{ + resourceDecisions?: Array<{ + decision?: string; + }>; + }>; + }>(config, '/authorization.v2.AuthorizationService/GetDecisionBulk', { + decisionRequests, + }); + + pdpLogger.debug({ msg: 'GetDecisionBulk response (roomAttributesChanged)', roomId: room._id, result: result.decisionResponses }); + + const nonCompliantUsers: IUser[] = []; + + result.decisionResponses?.forEach((resp, index) => { + const permitted = resp.resourceDecisions?.every((rd) => rd.decision === 'DECISION_PERMIT'); + if (!permitted && usersWithKeys[index]) { + nonCompliantUsers.push(usersWithKeys[index].user); + } + }); + + // Users without entity keys are also non-compliant + const usersWithoutKeys = users.filter((user) => !this.getUserEntityKey(user, config.defaultEntityKey)); + nonCompliantUsers.push(...(usersWithoutKeys as IUser[])); + + return nonCompliantUsers; } - async onSubjectAttributesChanged(_user: IUser, _next: IAbacAttributeDefinition[]): Promise { - throw new Error('ExternalPDP: onSubjectAttributesChanged not implemented'); + async evaluateUserRooms( + user: Pick, + rooms: AtLeast[], + ): Promise { + if (!rooms.length) { + return []; + } + + const config = await this.getConfig(); + + const entityKey = this.getUserEntityKey(user, config.defaultEntityKey); + if (!entityKey) { + return rooms as IRoom[]; + } + + const decisionRequests = rooms.map((room) => ({ + entityIdentifier: { + entityChain: { + entities: [this.buildEntityIdentifier(entityKey, config.defaultEntityKey)], + }, + }, + action: { name: 'read' }, + resources: [ + { + ephemeralId: room._id, + attributeValues: { fqns: this.buildAttributeFqns(room.abacAttributes ?? [], config.attributeNamespace) }, + }, + ], + })); + + const result = await this.apiCall<{ + decisionResponses?: Array<{ + resourceDecisions?: Array<{ + decision?: string; + }>; + }>; + }>(config, '/authorization.v2.AuthorizationService/GetDecisionBulk', { + decisionRequests, + }); + + pdpLogger.debug({ msg: 'GetDecisionBulk response (evaluateUserRooms)', userId: user._id, result: result.decisionResponses }); + + const nonCompliantRooms: IRoom[] = []; + + result.decisionResponses?.forEach((resp, index) => { + const permitted = resp.resourceDecisions?.every((rd) => rd.decision === 'DECISION_PERMIT'); + if (!permitted && rooms[index]) { + nonCompliantRooms.push(rooms[index] as IRoom); + } + }); + + return nonCompliantRooms; + } + + async onSubjectAttributesChanged(user: IUser, _next: IAbacAttributeDefinition[]): Promise { + const roomIds = user.__rooms; + if (!roomIds?.length) { + return []; + } + + const config = await this.getConfig(); + + const entityKey = this.getUserEntityKey(user, config.defaultEntityKey); + if (!entityKey) { + // Without an entity key we cannot evaluate, treat all ABAC rooms as non-compliant + return Rooms.find( + { + _id: { $in: roomIds }, + abacAttributes: { $exists: true, $ne: [] }, + }, + { projection: { _id: 1 } }, + ).toArray(); + } + + const abacRooms = await Rooms.find( + { + _id: { $in: roomIds }, + abacAttributes: { $exists: true, $ne: [] }, + }, + { projection: { _id: 1, abacAttributes: 1 } }, + ).toArray(); + + if (!abacRooms.length) { + return []; + } + + const decisionRequests = abacRooms.map((room) => ({ + entityIdentifier: { + entityChain: { + entities: [this.buildEntityIdentifier(entityKey, config.defaultEntityKey)], + }, + }, + action: { name: 'read' }, + resources: [ + { + ephemeralId: room._id, + attributeValues: { fqns: this.buildAttributeFqns(room.abacAttributes ?? [], config.attributeNamespace) }, + }, + ], + })); + + const result = await this.apiCall<{ + decisionResponses?: Array<{ + resourceDecisions?: Array<{ + ephemeralResourceId?: string; + decision?: string; + }>; + }>; + }>(config, '/authorization.v2.AuthorizationService/GetDecisionBulk', { + decisionRequests, + }); + + pdpLogger.debug({ msg: 'GetDecisionBulk response (subjectAttributesChanged)', userId: user._id, result: result.decisionResponses }); + + const nonCompliantRooms: IRoom[] = []; + + result.decisionResponses?.forEach((resp, index) => { + const permitted = 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/LocalPDP.ts b/ee/packages/abac/src/pdp/LocalPDP.ts index d1ed31f909752..1be298e45725a 100644 --- a/ee/packages/abac/src/pdp/LocalPDP.ts +++ b/ee/packages/abac/src/pdp/LocalPDP.ts @@ -89,6 +89,38 @@ export class LocalPDP implements IPolicyDecisionPoint { return Rooms.find(query, { projection: { _id: 1 } }).toArray(); } + async evaluateUserRooms( + user: Pick, + rooms: AtLeast[], + ): Promise { + const fullUser = await Users.findOneById(user._id); + if (!fullUser) { + return rooms as IRoom[]; + } + + const nonCompliant: IRoom[] = []; + for (const room of rooms) { + const attributes = room.abacAttributes ?? []; + if (!attributes.length) { + continue; + } + + const isCompliant = await Users.findOne( + { + _id: user._id, + $and: buildCompliantConditions(attributes), + }, + { projection: { _id: 1 } }, + ); + + if (!isCompliant) { + nonCompliant.push(room as IRoom); + } + } + + return nonCompliant; + } + async checkUsernamesMatchAttributes(usernames: string[], attributes: IAbacAttributeDefinition[], _object: IRoom): Promise { const nonCompliantUsersFromList = await Users.find( { diff --git a/ee/packages/abac/src/pdp/types.ts b/ee/packages/abac/src/pdp/types.ts index 27ea14fa51c74..8ec874ffb5759 100644 --- a/ee/packages/abac/src/pdp/types.ts +++ b/ee/packages/abac/src/pdp/types.ts @@ -16,4 +16,9 @@ export interface IPolicyDecisionPoint { ): Promise; onSubjectAttributesChanged(user: IUser, next: IAbacAttributeDefinition[]): Promise; + + evaluateUserRooms( + user: Pick, + rooms: AtLeast[], + ): Promise; } diff --git a/packages/core-services/src/types/IAbacService.ts b/packages/core-services/src/types/IAbacService.ts index e7f19e0ad1fd2..a7582f0df71de 100644 --- a/packages/core-services/src/types/IAbacService.ts +++ b/packages/core-services/src/types/IAbacService.ts @@ -46,4 +46,5 @@ export interface IAbacService { objectType: AbacObjectType, ): Promise; addSubjectAttributes(user: IUser, ldapUser: ILDAPEntry, map: Record, actor: AbacActor | undefined): Promise; + syncExternalPdpRooms(): Promise; } diff --git a/packages/core-typings/src/ServerAudit/IAuditServerAbacAction.ts b/packages/core-typings/src/ServerAudit/IAuditServerAbacAction.ts index 720e1c4d53c38..0403997609f1a 100644 --- a/packages/core-typings/src/ServerAudit/IAuditServerAbacAction.ts +++ b/packages/core-typings/src/ServerAudit/IAuditServerAbacAction.ts @@ -3,7 +3,7 @@ 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' | 'external-pdp-sync'; export type AbacActionPerformed = 'revoked-object-access' | 'granted-object-access'; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index c9890acec8662..f3fa4e240ad55 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -14,8 +14,26 @@ "ABAC": "Attribute Based Access Control", "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": "How often to re-evaluate all ABAC room memberships against the external PDP and evict non-compliant users.", "ABAC_Enabled_callout": "User attributes are synchronized via LDAP. <1>Learn more", "ABAC_Learn_More": "Learn about ABAC", "ABAC_automatically_disabled_callout": "ABAC automatically disabled", From dfca7a1ed4091f2d091b12d4fd4730ba6ff86caa Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 23 Mar 2026 13:20:28 -0600 Subject: [PATCH 02/41] smol --- apps/meteor/ee/server/configuration/abac.ts | 8 +- apps/meteor/ee/server/lib/ldap/Manager.ts | 5 +- apps/meteor/ee/server/settings/abac.ts | 57 ++++---- ee/packages/abac/src/index.ts | 62 +++++++-- ee/packages/abac/src/pdp/ExternalPDP.ts | 136 +++++++++----------- ee/packages/abac/src/pdp/index.ts | 1 + packages/i18n/src/locales/en.i18n.json | 30 ++--- 7 files changed, 167 insertions(+), 132 deletions(-) diff --git a/apps/meteor/ee/server/configuration/abac.ts b/apps/meteor/ee/server/configuration/abac.ts index c91dd52575b4f..17c532d2ed4c2 100644 --- a/apps/meteor/ee/server/configuration/abac.ts +++ b/apps/meteor/ee/server/configuration/abac.ts @@ -31,7 +31,7 @@ Meteor.startup(async () => { await import('../hooks/abac'); stopWatcher = settings.watch('ABAC_Enabled', async (value) => { - if (value) { + if (value && settings.get('ABAC_PDP_Type') !== 'external') { await LDAPEE.syncUsersAbacAttributes(Users.findLDAPUsers()); } }); @@ -42,14 +42,14 @@ Meteor.startup(async () => { const abacEnabled = settings.get('ABAC_Enabled'); const pdpType = settings.get('ABAC_PDP_Type'); - if (!abacEnabled || pdpType !== 'virtru') { + if (!abacEnabled || pdpType !== 'external') { if (await cronJobs.has(EXTERNAL_PDP_SYNC_JOB)) { await cronJobs.remove(EXTERNAL_PDP_SYNC_JOB); } return; } - const intervalValue = settings.get('ABAC_Virtru_Sync_Interval') || 'every_24_hours'; + const intervalValue = settings.get('ABAC_External_Sync_Interval') || 'every_24_hours'; const schedule = intervalToCronMap[intervalValue] ?? intervalToCronMap.every_24_hours; if (schedule !== lastSchedule && (await cronJobs.has(EXTERNAL_PDP_SYNC_JOB))) { @@ -61,7 +61,7 @@ Meteor.startup(async () => { } stopCronWatcher = settings.watchMultiple( - ['ABAC_Enabled', 'ABAC_PDP_Type', 'ABAC_Virtru_Sync_Interval'], + ['ABAC_Enabled', 'ABAC_PDP_Type', 'ABAC_External_Sync_Interval'], () => configureExternalPdpSync(), ); }, diff --git a/apps/meteor/ee/server/lib/ldap/Manager.ts b/apps/meteor/ee/server/lib/ldap/Manager.ts index a8ec3d84d9769..5abc641f88a62 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') === 'external' ) { return; } @@ -129,7 +130,7 @@ 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') === 'external') { return; } diff --git a/apps/meteor/ee/server/settings/abac.ts b/apps/meteor/ee/server/settings/abac.ts index 93c6e935cf5a9..7de7441caa15e 100644 --- a/apps/meteor/ee/server/settings/abac.ts +++ b/apps/meteor/ee/server/settings/abac.ts @@ -1,7 +1,7 @@ import { settingsRegistry } from '../../../app/settings/server'; const abacEnabledQuery = { _id: 'ABAC_Enabled', value: true }; -const virtruPdpQuery = [abacEnabledQuery, { _id: 'ABAC_PDP_Type', value: 'virtru' }]; +const externalPdpQuery = [abacEnabledQuery, { _id: 'ABAC_PDP_Type', value: 'external' }]; export function addSettings(): Promise { return settingsRegistry.addGroup('General', async function () { @@ -25,7 +25,7 @@ export function addSettings(): Promise { invalidValue: 'local', values: [ { key: 'local', i18nLabel: 'ABAC_PDP_Type_Local' }, - { key: 'virtru', i18nLabel: 'ABAC_PDP_Type_Virtru' }, + { key: 'external', i18nLabel: 'ABAC_PDP_Type_External' }, ], enableQuery: abacEnabledQuery, }); @@ -44,67 +44,68 @@ export function addSettings(): Promise { enableQuery: [abacEnabledQuery, { _id: 'ABAC_PDP_Type', value: 'local' }], }); - // Virtru PDP Configuration - await this.add('ABAC_Virtru_Base_URL', '', { + // External PDP Configuration + await this.add('ABAC_External_Base_URL', '', { type: 'string', public: false, invalidValue: '', - section: 'ABAC_Virtru_PDP_Configuration', - enableQuery: virtruPdpQuery, + section: 'ABAC_External_PDP_Configuration', + enableQuery: externalPdpQuery, }); - await this.add('ABAC_Virtru_Client_ID', '', { + await this.add('ABAC_External_Client_ID', '', { type: 'string', public: false, invalidValue: '', - section: 'ABAC_Virtru_PDP_Configuration', - enableQuery: virtruPdpQuery, + section: 'ABAC_External_PDP_Configuration', + enableQuery: externalPdpQuery, }); - await this.add('ABAC_Virtru_Client_Secret', '', { + await this.add('ABAC_External_Client_Secret', '', { type: 'password', public: false, invalidValue: '', - section: 'ABAC_Virtru_PDP_Configuration', - enableQuery: virtruPdpQuery, + section: 'ABAC_External_PDP_Configuration', + enableQuery: externalPdpQuery, }); - await this.add('ABAC_Virtru_OIDC_Endpoint', '', { + await this.add('ABAC_External_OIDC_Endpoint', '', { type: 'string', public: false, invalidValue: '', - section: 'ABAC_Virtru_PDP_Configuration', - enableQuery: virtruPdpQuery, + section: 'ABAC_External_PDP_Configuration', + i18nDescription: 'ABAC_External_OIDC_Endpoint_Description', + enableQuery: externalPdpQuery, }); - await this.add('ABAC_Virtru_Default_Entity_Key', 'emailAddress', { + await this.add('ABAC_External_Default_Entity_Key', 'emailAddress', { type: 'select', public: false, invalidValue: 'emailAddress', - section: 'ABAC_Virtru_PDP_Configuration', - i18nDescription: 'ABAC_Virtru_Default_Entity_Key_Description', + section: 'ABAC_External_PDP_Configuration', + i18nDescription: 'ABAC_External_Default_Entity_Key_Description', values: [ - { key: 'emailAddress', i18nLabel: 'ABAC_Virtru_Entity_Key_Email' }, - { key: 'oidcIdentifier', i18nLabel: 'ABAC_Virtru_Entity_Key_OIDC' }, + { key: 'emailAddress', i18nLabel: 'ABAC_External_Entity_Key_Email' }, + { key: 'oidcIdentifier', i18nLabel: 'ABAC_External_Entity_Key_OIDC' }, ], - enableQuery: virtruPdpQuery, + enableQuery: externalPdpQuery, }); - await this.add('ABAC_Virtru_Attribute_Namespace', 'example.com', { + await this.add('ABAC_External_Attribute_Namespace', 'example.com', { type: 'string', public: false, invalidValue: 'example.com', - section: 'ABAC_Virtru_PDP_Configuration', - i18nDescription: 'ABAC_Virtru_Attribute_Namespace_Description', - enableQuery: virtruPdpQuery, + section: 'ABAC_External_PDP_Configuration', + i18nDescription: 'ABAC_External_Attribute_Namespace_Description', + enableQuery: externalPdpQuery, }); - await this.add('ABAC_Virtru_Sync_Interval', 'every_24_hours', { + await this.add('ABAC_External_Sync_Interval', 'every_24_hours', { type: 'select', public: false, invalidValue: 'every_24_hours', - section: 'ABAC_Virtru_PDP_Configuration', + section: 'ABAC_External_PDP_Configuration', values: [ { key: 'every_1_hour', i18nLabel: 'every_hour' }, { key: 'every_6_hours', i18nLabel: 'every_six_hours' }, { key: 'every_12_hours', i18nLabel: 'every_12_hours' }, { key: 'every_24_hours', i18nLabel: 'every_24_hours' }, ], - enableQuery: virtruPdpQuery, + enableQuery: externalPdpQuery, }); }, ); diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 829cc50fa7b95..76eb62eae3a45 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -34,7 +34,7 @@ import { MAX_ABAC_ATTRIBUTE_KEYS, } from './helper'; import { logger } from './logger'; -import type { IPolicyDecisionPoint } from './pdp'; +import type { IPolicyDecisionPoint, ExternalPDPConfig } from './pdp'; import { LocalPDP, ExternalPDP } from './pdp'; // Limit concurrent user removals to avoid overloading the server with too many operations at once @@ -45,6 +45,15 @@ export class AbacService extends ServiceClass implements IAbacService { private pdp!: IPolicyDecisionPoint; + private externalPdpConfig: ExternalPDPConfig = { + baseUrl: '', + clientId: '', + clientSecret: '', + oidcEndpoint: '', + defaultEntityKey: 'emailAddress', + attributeNamespace: 'example.com', + }; + decisionCacheTimeout = 60; // seconds constructor() { @@ -53,10 +62,8 @@ export class AbacService extends ServiceClass implements IAbacService { this.onSettingChanged('ABAC_PDP_Type', async ({ setting }): Promise => { const { value } = setting; - if (value === 'local') { - this.setPdpStrategy('local'); - } else if (value === 'virtru') { - this.setPdpStrategy('external'); + if (value === 'local' || value === 'external') { + this.setPdpStrategy(value); } }); @@ -67,12 +74,33 @@ export class AbacService extends ServiceClass implements IAbacService { } this.decisionCacheTimeout = value; }); + + const externalPdpSettingsMap: Record = { + ABAC_External_Base_URL: 'baseUrl', + ABAC_External_Client_ID: 'clientId', + ABAC_External_Client_Secret: 'clientSecret', + ABAC_External_OIDC_Endpoint: 'oidcEndpoint', + ABAC_External_Default_Entity_Key: 'defaultEntityKey', + ABAC_External_Attribute_Namespace: 'attributeNamespace', + }; + + for (const [settingId, configKey] of Object.entries(externalPdpSettingsMap)) { + this.onSettingChanged(settingId, async ({ setting }): Promise => { + const { value } = setting; + if (typeof value === 'string') { + this.externalPdpConfig[configKey] = value; + if (this.pdp instanceof ExternalPDP) { + this.pdp.updateConfig({ ...this.externalPdpConfig }); + } + } + }); + } } setPdpStrategy(strategy: 'local' | 'external'): void { switch (strategy) { case 'external': - this.pdp = new ExternalPDP(); + this.pdp = new ExternalPDP({ ...this.externalPdpConfig }); break; case 'local': default: @@ -84,8 +112,26 @@ export class AbacService extends ServiceClass implements IAbacService { override async started(): Promise { this.decisionCacheTimeout = await Settings.get('Abac_Cache_Decision_Time_Seconds'); + const [baseUrl, clientId, clientSecret, oidcEndpoint, defaultEntityKey, attributeNamespace] = await Promise.all([ + Settings.get('ABAC_External_Base_URL'), + Settings.get('ABAC_External_Client_ID'), + Settings.get('ABAC_External_Client_Secret'), + Settings.get('ABAC_External_OIDC_Endpoint'), + Settings.get('ABAC_External_Default_Entity_Key'), + Settings.get('ABAC_External_Attribute_Namespace'), + ]); + + this.externalPdpConfig = { + baseUrl: baseUrl || '', + clientId: clientId || '', + clientSecret: clientSecret || '', + oidcEndpoint: oidcEndpoint || '', + defaultEntityKey: defaultEntityKey || 'emailAddress', + attributeNamespace: attributeNamespace || 'example.com', + }; + const pdpType = await Settings.get('ABAC_PDP_Type'); - if (pdpType === 'virtru') { + if (pdpType === 'external') { this.setPdpStrategy('external'); } } @@ -685,6 +731,6 @@ export class AbacService extends ServiceClass implements IAbacService { } export { LocalPDP, ExternalPDP } from './pdp'; -export type { IPolicyDecisionPoint } from './pdp'; +export type { IPolicyDecisionPoint, ExternalPDPConfig } from './pdp'; export default AbacService; diff --git a/ee/packages/abac/src/pdp/ExternalPDP.ts b/ee/packages/abac/src/pdp/ExternalPDP.ts index 1844ba9ccc8ef..33b586832483b 100644 --- a/ee/packages/abac/src/pdp/ExternalPDP.ts +++ b/ee/packages/abac/src/pdp/ExternalPDP.ts @@ -1,4 +1,3 @@ -import { Settings } from '@rocket.chat/core-services'; import type { IAbacAttributeDefinition, IRoom, IUser, AtLeast, ISubscription } from '@rocket.chat/core-typings'; import { Rooms, Users, Subscriptions } from '@rocket.chat/models'; import { serverFetch } from '@rocket.chat/server-fetch'; @@ -9,7 +8,7 @@ import type { IPolicyDecisionPoint } from './types'; const pdpLogger = logger.section('ExternalPDP'); -interface VirtruConfig { +export interface IExternalPDPConfig { baseUrl: string; clientId: string; clientSecret: string; @@ -18,41 +17,39 @@ interface VirtruConfig { attributeNamespace: string; } -interface TokenCache { +interface ITokenCache { accessToken: string; expiresAt: number; } export class ExternalPDP implements IPolicyDecisionPoint { - private tokenCache: TokenCache | null = null; - - private async getConfig(): 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'), - ]); - - return { baseUrl, clientId, clientSecret, oidcEndpoint, defaultEntityKey, attributeNamespace: attributeNamespace || 'example.com' }; + private tokenCache: ITokenCache | null = null; + + private config: IExternalPDPConfig; + + constructor(config: IExternalPDPConfig) { + this.config = config; } - private async getClientToken(config: VirtruConfig): Promise { + updateConfig(config: IExternalPDPConfig): void { + this.config = config; + this.tokenCache = null; + } + + private async getClientToken(): Promise { if (this.tokenCache && Date.now() < this.tokenCache.expiresAt) { return this.tokenCache.accessToken; } const response = await serverFetch( - `${config.oidcEndpoint}/protocol/openid-connect/token`, + `${this.config.oidcEndpoint}/protocol/openid-connect/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', - client_id: config.clientId, - client_secret: config.clientSecret, + client_id: this.config.clientId, + client_secret: this.config.clientSecret, }), ignoreSsrfValidation: true, }, @@ -75,11 +72,11 @@ export class ExternalPDP implements IPolicyDecisionPoint { return data.access_token; } - private async apiCall(config: VirtruConfig, endpoint: string, body: unknown): Promise { - const token = await this.getClientToken(config); + private async apiCall(endpoint: string, body: unknown): Promise { + const token = await this.getClientToken(); const response = await serverFetch( - `${config.baseUrl}${endpoint}`, + `${this.config.baseUrl}${endpoint}`, { method: 'POST', headers: { @@ -94,40 +91,43 @@ export class ExternalPDP implements IPolicyDecisionPoint { if (!response.ok) { const text = await response.text().catch(() => ''); - throw new Error(`Virtru API call to ${endpoint} failed: ${response.status} ${response.statusText} - ${text}`); + pdpLogger.error({ msg: 'External PDP API call failed', endpoint, status: response.status, response: text }); + throw new Error('External PDP call failed'); } return response.json() as Promise; } - private buildAttributeFqns(attributes: IAbacAttributeDefinition[], namespace: string): string[] { - if (!namespace) { - throw new Error('ExternalPDP: attribute namespace is not configured'); + private buildAttributeFqns(attributes: IAbacAttributeDefinition[]): string[] { + if (!this.config.attributeNamespace) { + throw new Error('Attribute namespace is not configured for ExternalPDP'); } - return attributes.flatMap((attr) => attr.values.map((value) => `https://${namespace}/attr/${attr.key}/value/${value}`)); + return attributes.flatMap((attr) => + attr.values.map((value) => `https://${this.config.attributeNamespace}/attr/${attr.key}/value/${value}`), + ); } - private buildEntityIdentifier(entityKey: string, defaultEntityKey: string) { - if (defaultEntityKey === 'emailAddress') { + private buildEntityIdentifier(entityKey: string) { + if (this.config.defaultEntityKey === 'emailAddress') { return { emailAddress: entityKey }; } return { id: entityKey }; } - private getUserEntityKey(user: Pick, defaultEntityKey: string): string | undefined { - if (!defaultEntityKey) { - throw new Error('ExternalPDP: default entity key is not configured'); + private getUserEntityKey(user: Pick): string | undefined { + if (!this.config.defaultEntityKey) { + throw new Error('Default entity key is not configured for ExternalPDP'); } - switch (defaultEntityKey) { + switch (this.config.defaultEntityKey) { case 'emailAddress': return user.emails?.[0]?.address; case 'oidcIdentifier': - return user.username; + return user.username; // For now, username, we're gonna change this to find the right oidc identifier for the user default: - throw new Error(`ExternalPDP: unknown entity key type: ${defaultEntityKey}`); + throw new Error('Unsupported default entity key configuration for ExternalPDP'); } } @@ -137,31 +137,30 @@ export class ExternalPDP implements IPolicyDecisionPoint { _userSub: ISubscription, _decisionCacheTimeout: number, ): Promise<{ granted: boolean; userToRemove?: IUser }> { - const config = await this.getConfig(); const attributes = room.abacAttributes ?? []; if (!attributes.length) { return { granted: true }; } - const fullUser = await Users.findOneById(user._id, { projection: { _id: 1, emails: 1, username: 1 } }); + const fullUser = await Users.findOneById(user._id); if (!fullUser) { return { granted: false }; } - const entityKey = this.getUserEntityKey(fullUser, config.defaultEntityKey); + const entityKey = this.getUserEntityKey(fullUser); if (!entityKey) { pdpLogger.warn({ msg: 'User has no entity key for external PDP evaluation', userId: user._id }); return { granted: false }; } - const fqns = this.buildAttributeFqns(attributes, config.attributeNamespace); + const fqns = this.buildAttributeFqns(attributes); const result = await this.apiCall<{ decisionResponses?: Array<{ decision?: string; }>; - }>(config, '/authorization.AuthorizationService/GetDecisions', { + }>('/authorization.AuthorizationService/GetDecisions', { decisionRequests: [ { actions: [{ standard: 1 }], @@ -174,7 +173,7 @@ export class ExternalPDP implements IPolicyDecisionPoint { entityChains: [ { id: 'rc-access-check', - entities: [this.buildEntityIdentifier(entityKey, config.defaultEntityKey)], + entities: [this.buildEntityIdentifier(entityKey)], }, ], }, @@ -187,10 +186,7 @@ export class ExternalPDP implements IPolicyDecisionPoint { const granted = decision === 'DECISION_PERMIT'; if (!granted) { - const userToRemove = await Users.findOneById(user._id); - if (userToRemove) { - return { granted: false, userToRemove }; - } + return { granted: false, userToRemove: fullUser }; } return { granted }; @@ -201,14 +197,11 @@ export class ExternalPDP implements IPolicyDecisionPoint { return; } - const config = await this.getConfig(); - const fqns = this.buildAttributeFqns(attributes, config.attributeNamespace); - const users = await Users.find({ username: { $in: usernames } }, { projection: { _id: 1, emails: 1, username: 1 } }).toArray(); const decisionRequests = users .map((user) => { - const entityKey = this.getUserEntityKey(user, config.defaultEntityKey); + const entityKey = this.getUserEntityKey(user); if (!entityKey) { return null; } @@ -216,14 +209,14 @@ export class ExternalPDP implements IPolicyDecisionPoint { return { entityIdentifier: { entityChain: { - entities: [this.buildEntityIdentifier(entityKey, config.defaultEntityKey)], + entities: [this.buildEntityIdentifier(entityKey)], }, }, action: { name: 'read' }, resources: [ { ephemeralId: object._id, - attributeValues: { fqns }, + attributeValues: { fqns: this.buildAttributeFqns(attributes) }, }, ], }; @@ -240,7 +233,7 @@ export class ExternalPDP implements IPolicyDecisionPoint { decision?: string; }>; }>; - }>(config, '/authorization.v2.AuthorizationService/GetDecisionBulk', { + }>('/authorization.v2.AuthorizationService/GetDecisionBulk', { decisionRequests, }); @@ -263,9 +256,6 @@ export class ExternalPDP implements IPolicyDecisionPoint { return []; } - const config = await this.getConfig(); - const fqns = this.buildAttributeFqns(newAttributes, config.attributeNamespace); - const subscriptions = await Subscriptions.findByRoomId(room._id, { projection: { 'u._id': 1 } }).toArray(); const userIds = subscriptions.map((s) => s.u._id); @@ -278,25 +268,25 @@ export class ExternalPDP implements IPolicyDecisionPoint { const usersWithKeys = users .map((user) => ({ user, - entityKey: this.getUserEntityKey(user, config.defaultEntityKey), + entityKey: this.getUserEntityKey(user), })) .filter((entry): entry is { user: IUser; entityKey: string } => !!entry.entityKey); if (!usersWithKeys.length) { - return users as IUser[]; + return users; } const decisionRequests = usersWithKeys.map(({ entityKey }) => ({ entityIdentifier: { entityChain: { - entities: [this.buildEntityIdentifier(entityKey, config.defaultEntityKey)], + entities: [this.buildEntityIdentifier(entityKey)], }, }, action: { name: 'read' }, resources: [ { ephemeralId: room._id, - attributeValues: { fqns }, + attributeValues: { fqns: this.buildAttributeFqns(newAttributes) }, }, ], })); @@ -307,7 +297,7 @@ export class ExternalPDP implements IPolicyDecisionPoint { decision?: string; }>; }>; - }>(config, '/authorization.v2.AuthorizationService/GetDecisionBulk', { + }>('/authorization.v2.AuthorizationService/GetDecisionBulk', { decisionRequests, }); @@ -323,8 +313,8 @@ export class ExternalPDP implements IPolicyDecisionPoint { }); // Users without entity keys are also non-compliant - const usersWithoutKeys = users.filter((user) => !this.getUserEntityKey(user, config.defaultEntityKey)); - nonCompliantUsers.push(...(usersWithoutKeys as IUser[])); + const usersWithoutKeys = users.filter((user) => !this.getUserEntityKey(user)); + nonCompliantUsers.push(...usersWithoutKeys); return nonCompliantUsers; } @@ -337,9 +327,7 @@ export class ExternalPDP implements IPolicyDecisionPoint { return []; } - const config = await this.getConfig(); - - const entityKey = this.getUserEntityKey(user, config.defaultEntityKey); + const entityKey = this.getUserEntityKey(user); if (!entityKey) { return rooms as IRoom[]; } @@ -347,14 +335,14 @@ export class ExternalPDP implements IPolicyDecisionPoint { const decisionRequests = rooms.map((room) => ({ entityIdentifier: { entityChain: { - entities: [this.buildEntityIdentifier(entityKey, config.defaultEntityKey)], + entities: [this.buildEntityIdentifier(entityKey)], }, }, action: { name: 'read' }, resources: [ { ephemeralId: room._id, - attributeValues: { fqns: this.buildAttributeFqns(room.abacAttributes ?? [], config.attributeNamespace) }, + attributeValues: { fqns: this.buildAttributeFqns(room.abacAttributes ?? []) }, }, ], })); @@ -365,7 +353,7 @@ export class ExternalPDP implements IPolicyDecisionPoint { decision?: string; }>; }>; - }>(config, '/authorization.v2.AuthorizationService/GetDecisionBulk', { + }>('/authorization.v2.AuthorizationService/GetDecisionBulk', { decisionRequests, }); @@ -389,9 +377,7 @@ export class ExternalPDP implements IPolicyDecisionPoint { return []; } - const config = await this.getConfig(); - - const entityKey = this.getUserEntityKey(user, config.defaultEntityKey); + const entityKey = this.getUserEntityKey(user); if (!entityKey) { // Without an entity key we cannot evaluate, treat all ABAC rooms as non-compliant return Rooms.find( @@ -418,14 +404,14 @@ export class ExternalPDP implements IPolicyDecisionPoint { const decisionRequests = abacRooms.map((room) => ({ entityIdentifier: { entityChain: { - entities: [this.buildEntityIdentifier(entityKey, config.defaultEntityKey)], + entities: [this.buildEntityIdentifier(entityKey)], }, }, action: { name: 'read' }, resources: [ { ephemeralId: room._id, - attributeValues: { fqns: this.buildAttributeFqns(room.abacAttributes ?? [], config.attributeNamespace) }, + attributeValues: { fqns: this.buildAttributeFqns(room.abacAttributes ?? []) }, }, ], })); @@ -437,7 +423,7 @@ export class ExternalPDP implements IPolicyDecisionPoint { decision?: string; }>; }>; - }>(config, '/authorization.v2.AuthorizationService/GetDecisionBulk', { + }>('/authorization.v2.AuthorizationService/GetDecisionBulk', { decisionRequests, }); diff --git a/ee/packages/abac/src/pdp/index.ts b/ee/packages/abac/src/pdp/index.ts index 210941a411dbc..90446df1d7aa6 100644 --- a/ee/packages/abac/src/pdp/index.ts +++ b/ee/packages/abac/src/pdp/index.ts @@ -1,3 +1,4 @@ export { LocalPDP } from './LocalPDP'; export { ExternalPDP } from './ExternalPDP'; +export type { IExternalPDPConfig as ExternalPDPConfig } from './ExternalPDP'; export type { IPolicyDecisionPoint } from './types'; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index f3fa4e240ad55..da406cc9ed303 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -17,23 +17,23 @@ "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_PDP_Type_External": "External", "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": "How often to re-evaluate all ABAC room memberships against the external PDP and evict non-compliant users.", + "ABAC_External_PDP_Configuration": "External PDP Configuration", + "ABAC_External_Base_URL": "Base URL", + "ABAC_External_Client_ID": "Client ID", + "ABAC_External_Client_Secret": "Client Secret", + "ABAC_External_OIDC_Endpoint": "OIDC Endpoint", + "ABAC_External_OIDC_Endpoint_Description": "Base OIDC realm URL used for client credentials authentication (e.g. https://keycloak.example.com/auth/realms/opentdf).", + "ABAC_External_Default_Entity_Key": "Default Entity Key", + "ABAC_External_Default_Entity_Key_Description": "The `entity` identifier that will be sent to the ERS.", + "ABAC_External_Entity_Key_Email": "Email Address", + "ABAC_External_Entity_Key_OIDC": "OIDC Identifier", + "ABAC_External_Attribute_Namespace": "Attribute Namespace", + "ABAC_External_Attribute_Namespace_Description": "The namespace used to build attribute FQNs (e.g. opentdf.io produces https://opentdf.io/attr/{key}/value/{value}).", + "ABAC_External_Sync_Interval": "Membership Sync Interval", + "ABAC_External_Sync_Interval_Description": "How often to re-evaluate all ABAC room memberships against the external PDP and evict non-compliant users.", "ABAC_Enabled_callout": "User attributes are synchronized via LDAP. <1>Learn more", "ABAC_Learn_More": "Learn about ABAC", "ABAC_automatically_disabled_callout": "ABAC automatically disabled", From 79e7a0c6f0f6d6ead1e82ff23809a62fcd41455f Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 23 Mar 2026 13:23:41 -0600 Subject: [PATCH 03/41] remove param --- ee/packages/abac/src/pdp/ExternalPDP.ts | 44 ++++++++++--------------- 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/ee/packages/abac/src/pdp/ExternalPDP.ts b/ee/packages/abac/src/pdp/ExternalPDP.ts index 33b586832483b..21db7da043339 100644 --- a/ee/packages/abac/src/pdp/ExternalPDP.ts +++ b/ee/packages/abac/src/pdp/ExternalPDP.ts @@ -41,20 +41,16 @@ export class ExternalPDP implements IPolicyDecisionPoint { return this.tokenCache.accessToken; } - const response = await serverFetch( - `${this.config.oidcEndpoint}/protocol/openid-connect/token`, - { - method: 'POST', - 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, - }, - true, - ); + const response = await serverFetch(`${this.config.oidcEndpoint}/protocol/openid-connect/token`, { + method: 'POST', + 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}`); @@ -75,19 +71,15 @@ export class ExternalPDP implements IPolicyDecisionPoint { private async apiCall(endpoint: string, body: unknown): Promise { const token = await this.getClientToken(); - const response = await serverFetch( - `${this.config.baseUrl}${endpoint}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify(body), - ignoreSsrfValidation: true, + const response = await serverFetch(`${this.config.baseUrl}${endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, }, - true, - ); + body: JSON.stringify(body), + ignoreSsrfValidation: true, + }); if (!response.ok) { const text = await response.text().catch(() => ''); From 9dc5d19a45507abe7005f38c4a7f9b70e4d78754 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 23 Mar 2026 13:32:27 -0600 Subject: [PATCH 04/41] caching --- ee/packages/abac/src/index.ts | 20 +++++++++++++++-- ee/packages/abac/src/pdp/ExternalPDP.ts | 4 +--- ee/packages/abac/src/pdp/LocalPDP.ts | 29 ++----------------------- ee/packages/abac/src/pdp/types.ts | 4 +--- 4 files changed, 22 insertions(+), 35 deletions(-) diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 76eb62eae3a45..212dd49fac663 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -546,6 +546,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, @@ -570,13 +578,21 @@ export class AbacService extends ServiceClass implements IAbacService { 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; + } + + const decision = await this.pdp.canAccessObject(room, user); 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; } diff --git a/ee/packages/abac/src/pdp/ExternalPDP.ts b/ee/packages/abac/src/pdp/ExternalPDP.ts index 21db7da043339..bd17d6d9ee78b 100644 --- a/ee/packages/abac/src/pdp/ExternalPDP.ts +++ b/ee/packages/abac/src/pdp/ExternalPDP.ts @@ -1,4 +1,4 @@ -import type { IAbacAttributeDefinition, IRoom, IUser, AtLeast, ISubscription } from '@rocket.chat/core-typings'; +import type { IAbacAttributeDefinition, IRoom, IUser, AtLeast } from '@rocket.chat/core-typings'; import { Rooms, Users, Subscriptions } from '@rocket.chat/models'; import { serverFetch } from '@rocket.chat/server-fetch'; @@ -126,8 +126,6 @@ export class ExternalPDP implements IPolicyDecisionPoint { async canAccessObject( room: AtLeast, user: AtLeast, - _userSub: ISubscription, - _decisionCacheTimeout: number, ): Promise<{ granted: boolean; userToRemove?: IUser }> { const attributes = room.abacAttributes ?? []; diff --git a/ee/packages/abac/src/pdp/LocalPDP.ts b/ee/packages/abac/src/pdp/LocalPDP.ts index 1be298e45725a..ad599bf42f50e 100644 --- a/ee/packages/abac/src/pdp/LocalPDP.ts +++ b/ee/packages/abac/src/pdp/LocalPDP.ts @@ -1,38 +1,15 @@ -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 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 +27,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 }; } diff --git a/ee/packages/abac/src/pdp/types.ts b/ee/packages/abac/src/pdp/types.ts index 8ec874ffb5759..26149d5f6038a 100644 --- a/ee/packages/abac/src/pdp/types.ts +++ b/ee/packages/abac/src/pdp/types.ts @@ -1,11 +1,9 @@ -import type { IAbacAttributeDefinition, IRoom, IUser, AtLeast, ISubscription } from '@rocket.chat/core-typings'; +import type { IAbacAttributeDefinition, IRoom, IUser, AtLeast } from '@rocket.chat/core-typings'; export interface IPolicyDecisionPoint { canAccessObject( room: AtLeast, user: AtLeast, - userSub: ISubscription, - decisionCacheTimeout: number, ): Promise<{ granted: boolean; userToRemove?: IUser }>; checkUsernamesMatchAttributes(usernames: string[], attributes: IAbacAttributeDefinition[], object: IRoom): Promise; From 95f4f309f0ecd5ee1199ac36f7e315b1971cc365 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 23 Mar 2026 14:19:38 -0600 Subject: [PATCH 05/41] enable cache setting --- apps/meteor/ee/server/settings/abac.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/ee/server/settings/abac.ts b/apps/meteor/ee/server/settings/abac.ts index 7de7441caa15e..dfc3d2f724ac4 100644 --- a/apps/meteor/ee/server/settings/abac.ts +++ b/apps/meteor/ee/server/settings/abac.ts @@ -41,7 +41,7 @@ export function addSettings(): Promise { public: true, section: 'ABAC', invalidValue: 0, - enableQuery: [abacEnabledQuery, { _id: 'ABAC_PDP_Type', value: 'local' }], + enableQuery: [abacEnabledQuery], }); // External PDP Configuration From 17448a9346a911d860c9c83f23b706b635969549 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 23 Mar 2026 15:48:58 -0600 Subject: [PATCH 06/41] cron for continus check --- apps/meteor/ee/server/configuration/abac.ts | 22 +--- apps/meteor/ee/server/settings/abac.ts | 13 +- ee/packages/abac/src/index.ts | 74 ++++------- ee/packages/abac/src/pdp/ExternalPDP.ts | 119 ++++++++++++------ ee/packages/abac/src/pdp/LocalPDP.ts | 35 +----- ee/packages/abac/src/pdp/types.ts | 8 +- .../core-services/src/types/IAbacService.ts | 2 +- packages/i18n/src/locales/en.i18n.json | 2 +- 8 files changed, 129 insertions(+), 146 deletions(-) diff --git a/apps/meteor/ee/server/configuration/abac.ts b/apps/meteor/ee/server/configuration/abac.ts index 17c532d2ed4c2..16d91aa9fde30 100644 --- a/apps/meteor/ee/server/configuration/abac.ts +++ b/apps/meteor/ee/server/configuration/abac.ts @@ -9,13 +9,6 @@ import { LDAPEE } from '../sdk'; const EXTERNAL_PDP_SYNC_JOB = 'ABAC_External_PDP_Sync'; -const intervalToCronMap: Record = { - every_1_hour: '0 * * * *', - every_6_hours: '0 */6 * * *', - every_12_hours: '0 */12 * * *', - every_24_hours: '0 0 * * *', -}; - Meteor.startup(async () => { let stopWatcher: () => void; let stopCronWatcher: () => void; @@ -36,7 +29,6 @@ Meteor.startup(async () => { } }); - // External PDP sync cron let lastSchedule: string; async function configureExternalPdpSync(): Promise { const abacEnabled = settings.get('ABAC_Enabled'); @@ -49,21 +41,17 @@ Meteor.startup(async () => { return; } - const intervalValue = settings.get('ABAC_External_Sync_Interval') || 'every_24_hours'; - const schedule = intervalToCronMap[intervalValue] ?? intervalToCronMap.every_24_hours; + const cronValue = settings.get('ABAC_External_Sync_Interval'); - if (schedule !== lastSchedule && (await cronJobs.has(EXTERNAL_PDP_SYNC_JOB))) { + if (cronValue !== lastSchedule && (await cronJobs.has(EXTERNAL_PDP_SYNC_JOB))) { await cronJobs.remove(EXTERNAL_PDP_SYNC_JOB); } - lastSchedule = schedule; - await cronJobs.add(EXTERNAL_PDP_SYNC_JOB, schedule, () => Abac.syncExternalPdpRooms()); + lastSchedule = cronValue; + await cronJobs.add(EXTERNAL_PDP_SYNC_JOB, cronValue, () => Abac.evaluateRoomMembership()); } - stopCronWatcher = settings.watchMultiple( - ['ABAC_Enabled', 'ABAC_PDP_Type', 'ABAC_External_Sync_Interval'], - () => configureExternalPdpSync(), - ); + stopCronWatcher = settings.watchMultiple(['ABAC_PDP_Type', 'ABAC_External_Sync_Interval'], () => configureExternalPdpSync()); }, down: async () => { stopWatcher?.(); diff --git a/apps/meteor/ee/server/settings/abac.ts b/apps/meteor/ee/server/settings/abac.ts index dfc3d2f724ac4..0cd778851b7f8 100644 --- a/apps/meteor/ee/server/settings/abac.ts +++ b/apps/meteor/ee/server/settings/abac.ts @@ -94,17 +94,12 @@ export function addSettings(): Promise { i18nDescription: 'ABAC_External_Attribute_Namespace_Description', enableQuery: externalPdpQuery, }); - await this.add('ABAC_External_Sync_Interval', 'every_24_hours', { - type: 'select', + await this.add('ABAC_External_Sync_Interval', '*/5 * * * *', { + type: 'string', public: false, - invalidValue: 'every_24_hours', + invalidValue: '*/5 * * * *', section: 'ABAC_External_PDP_Configuration', - values: [ - { key: 'every_1_hour', i18nLabel: 'every_hour' }, - { key: 'every_6_hours', i18nLabel: 'every_six_hours' }, - { key: 'every_12_hours', i18nLabel: 'every_12_hours' }, - { key: 'every_24_hours', i18nLabel: 'every_24_hours' }, - ], + i18nDescription: 'ABAC_External_Sync_Interval_Description', enableQuery: externalPdpQuery, }); }, diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 212dd49fac663..33c22a6ba43dc 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -677,29 +677,20 @@ export class AbacService extends ServiceClass implements IAbacService { } } - async syncExternalPdpRooms(): Promise { - logger.info('Starting external PDP room membership sync'); - - // Build a map of ABAC rooms for quick lookup - const abacRoomsMap = new Map(); - const abacRoomsCursor = Rooms.find( + async evaluateRoomMembership(): Promise { + const abacRooms = await Rooms.find( { abacAttributes: { $exists: true, $ne: [] } }, { projection: { _id: 1, t: 1, teamMain: 1, abacAttributes: 1 } }, - ); + ).toArray(); - for await (const room of abacRoomsCursor) { - abacRoomsMap.set(room._id, room); - } - - if (!abacRoomsMap.size) { - logger.info('No ABAC-managed rooms found, skipping sync'); + if (!abacRooms.length) { return; } - const abacRoomIds = Array.from(abacRoomsMap.keys()); + const abacRoomById = Object.fromEntries(abacRooms.map((room) => [room._id, room])); + const abacRoomIds = abacRooms.map((room) => room._id); - // Get all active users that are members of at least one ABAC room - const usersCursor = Users.find( + const users = Users.find( { active: true, __rooms: { $in: abacRoomIds }, @@ -707,42 +698,29 @@ export class AbacService extends ServiceClass implements IAbacService { { projection: { _id: 1, emails: 1, username: 1, __rooms: 1 } }, ); - let userCount = 0; - let evictedCount = 0; - - for await (const user of usersCursor) { - // Use __rooms to resolve the user's ABAC rooms directly from the map — no extra DB query - const userAbacRooms = (user.__rooms ?? []) - .map((rid) => abacRoomsMap.get(rid)) - .filter((room): room is IRoom => !!room); + const entries = ( + await users + .map((user) => { + const rooms = (user.__rooms ?? []).map((rid) => abacRoomById[rid]).filter(Boolean); + return rooms.length ? { user, rooms } : null; + }) + .toArray() + ).filter(Boolean) as Array<{ user: Pick; rooms: IRoom[] }>; - if (!userAbacRooms.length) { - continue; - } - - try { - const nonCompliantRooms = await this.pdp.evaluateUserRooms(user, userAbacRooms); + if (!entries.length) { + return; + } - if (!nonCompliantRooms.length) { - userCount++; - continue; - } + try { + const nonCompliant = await this.pdp.evaluateUserRooms(entries); - await Promise.all( - nonCompliantRooms.map((room) => limit(() => this.removeUserFromRoom(room, user as IUser, 'external-pdp-sync'))), - ); - evictedCount += nonCompliantRooms.length; - userCount++; - } catch (err) { - logger.error({ - msg: 'External PDP sync failed for user', - userId: user._id, - err, - }); - } + // TODO: this should be in a persistent queue + await Promise.all( + nonCompliant.map(({ user, room }) => limit(() => this.removeUserFromRoom(room, user as IUser, 'external-pdp-sync'))), + ); + } catch (err) { + logger.error({ msg: 'Failed to evaluate room membership', err }); } - - logger.info({ msg: 'External PDP room membership sync completed', userCount, evictedCount }); } } diff --git a/ee/packages/abac/src/pdp/ExternalPDP.ts b/ee/packages/abac/src/pdp/ExternalPDP.ts index bd17d6d9ee78b..905e04f8a56fa 100644 --- a/ee/packages/abac/src/pdp/ExternalPDP.ts +++ b/ee/packages/abac/src/pdp/ExternalPDP.ts @@ -310,55 +310,98 @@ export class ExternalPDP implements IPolicyDecisionPoint { } async evaluateUserRooms( - user: Pick, - rooms: AtLeast[], - ): Promise { - if (!rooms.length) { - return []; + entries: Array<{ + user: Pick; + rooms: AtLeast[]; + }>, + ): Promise; room: IRoom }>> { + const requestIndex: Array<{ user: Pick; room: AtLeast }> = []; + const allRequests: unknown[] = []; + + for (const { user, rooms } of entries) { + const entityKey = this.getUserEntityKey(user); + if (!entityKey) { + for (const room of rooms) { + requestIndex.push({ user, room }); + allRequests.push(null); + } + 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 ?? []) }, + }, + ], + }); + } } - const entityKey = this.getUserEntityKey(user); - if (!entityKey) { - return rooms as IRoom[]; + if (!allRequests.length) { + return []; } - const decisionRequests = rooms.map((room) => ({ - entityIdentifier: { - entityChain: { - entities: [this.buildEntityIdentifier(entityKey)], - }, - }, - action: { name: 'read' }, - resources: [ - { - ephemeralId: room._id, - attributeValues: { fqns: this.buildAttributeFqns(room.abacAttributes ?? []) }, - }, - ], - })); + // Batch into chunks of 200 (GetDecisionBulk limit) + const BATCH_SIZE = 200; + const allDecisions: Array = []; - const result = await this.apiCall<{ - decisionResponses?: Array<{ - resourceDecisions?: Array<{ - decision?: string; - }>; - }>; - }>('/authorization.v2.AuthorizationService/GetDecisionBulk', { - decisionRequests, - }); + for (let i = 0; i < allRequests.length; i += BATCH_SIZE) { + const batch = allRequests.slice(i, i + BATCH_SIZE); + const validBatch = batch.filter(Boolean); - pdpLogger.debug({ msg: 'GetDecisionBulk response (evaluateUserRooms)', userId: user._id, result: result.decisionResponses }); + if (!validBatch.length) { + allDecisions.push(...batch.map(() => undefined)); + continue; + } - const nonCompliantRooms: IRoom[] = []; + const result = await this.apiCall<{ + decisionResponses?: Array<{ + resourceDecisions?: Array<{ + decision?: string; + }>; + }>; + }>('/authorization.v2.AuthorizationService/GetDecisionBulk', { + decisionRequests: validBatch, + }); + + pdpLogger.debug({ + msg: 'GetDecisionBulk response (evaluateUserRooms)', + batch: `${i}-${i + batch.length}`, + result: result.decisionResponses, + }); + + let resultIdx = 0; + for (const req of batch) { + if (!req) { + allDecisions.push(undefined); + } else { + const resp = result.decisionResponses?.[resultIdx]; + const permitted = resp?.resourceDecisions?.every((rd) => rd.decision === 'DECISION_PERMIT'); + allDecisions.push(permitted ? 'DECISION_PERMIT' : undefined); + resultIdx++; + } + } + } - result.decisionResponses?.forEach((resp, index) => { - const permitted = resp.resourceDecisions?.every((rd) => rd.decision === 'DECISION_PERMIT'); - if (!permitted && rooms[index]) { - nonCompliantRooms.push(rooms[index] as IRoom); + const nonCompliant: Array<{ user: Pick; room: IRoom }> = []; + + allDecisions.forEach((decision, index) => { + if (decision !== 'DECISION_PERMIT' && requestIndex[index]) { + nonCompliant.push({ user: requestIndex[index].user, room: requestIndex[index].room as IRoom }); } }); - return nonCompliantRooms; + return nonCompliant; } async onSubjectAttributesChanged(user: IUser, _next: IAbacAttributeDefinition[]): Promise { diff --git a/ee/packages/abac/src/pdp/LocalPDP.ts b/ee/packages/abac/src/pdp/LocalPDP.ts index ad599bf42f50e..5d5db6a899c75 100644 --- a/ee/packages/abac/src/pdp/LocalPDP.ts +++ b/ee/packages/abac/src/pdp/LocalPDP.ts @@ -65,35 +65,12 @@ export class LocalPDP implements IPolicyDecisionPoint { } async evaluateUserRooms( - user: Pick, - rooms: AtLeast[], - ): Promise { - const fullUser = await Users.findOneById(user._id); - if (!fullUser) { - return rooms as IRoom[]; - } - - const nonCompliant: IRoom[] = []; - for (const room of rooms) { - const attributes = room.abacAttributes ?? []; - if (!attributes.length) { - continue; - } - - const isCompliant = await Users.findOne( - { - _id: user._id, - $and: buildCompliantConditions(attributes), - }, - { projection: { _id: 1 } }, - ); - - if (!isCompliant) { - nonCompliant.push(room as IRoom); - } - } - - return nonCompliant; + _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 { diff --git a/ee/packages/abac/src/pdp/types.ts b/ee/packages/abac/src/pdp/types.ts index 26149d5f6038a..2cb43cc12fc0b 100644 --- a/ee/packages/abac/src/pdp/types.ts +++ b/ee/packages/abac/src/pdp/types.ts @@ -16,7 +16,9 @@ export interface IPolicyDecisionPoint { onSubjectAttributesChanged(user: IUser, next: IAbacAttributeDefinition[]): Promise; evaluateUserRooms( - user: Pick, - rooms: AtLeast[], - ): Promise; + entries: Array<{ + user: Pick; + rooms: AtLeast[]; + }>, + ): Promise; room: IRoom }>>; } diff --git a/packages/core-services/src/types/IAbacService.ts b/packages/core-services/src/types/IAbacService.ts index a7582f0df71de..2ff09cc489488 100644 --- a/packages/core-services/src/types/IAbacService.ts +++ b/packages/core-services/src/types/IAbacService.ts @@ -46,5 +46,5 @@ export interface IAbacService { objectType: AbacObjectType, ): Promise; addSubjectAttributes(user: IUser, ldapUser: ILDAPEntry, map: Record, actor: AbacActor | undefined): Promise; - syncExternalPdpRooms(): Promise; + evaluateRoomMembership(): Promise; } diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index da406cc9ed303..9466bb6a321b2 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -33,7 +33,7 @@ "ABAC_External_Attribute_Namespace": "Attribute Namespace", "ABAC_External_Attribute_Namespace_Description": "The namespace used to build attribute FQNs (e.g. opentdf.io produces https://opentdf.io/attr/{key}/value/{value}).", "ABAC_External_Sync_Interval": "Membership Sync Interval", - "ABAC_External_Sync_Interval_Description": "How often to re-evaluate all ABAC room memberships against the external PDP and evict non-compliant users.", + "ABAC_External_Sync_Interval_Description": "Cron expression for how often to re-evaluate all ABAC room memberships against the external PDP and evict non-compliant users (e.g. */5 * * * * for every 5 minutes).", "ABAC_Enabled_callout": "User attributes are synchronized via LDAP. <1>Learn more", "ABAC_Learn_More": "Learn about ABAC", "ABAC_automatically_disabled_callout": "ABAC automatically disabled", From da988e60b89c1bfd9df346a975636b0b745d2c12 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 24 Mar 2026 11:05:41 -0600 Subject: [PATCH 07/41] change how pdp is started --- .../abac/src/can-access-object.spec.ts | 1 + ee/packages/abac/src/index.ts | 40 +++++++++++++++---- ee/packages/abac/src/pdp/ExternalPDP.ts | 10 +++-- ee/packages/abac/src/service.spec.ts | 1 + .../subject-attributes-validations.spec.ts | 1 + .../abac/src/user-auto-removal.spec.ts | 1 + 6 files changed, 43 insertions(+), 11 deletions(-) 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/index.ts b/ee/packages/abac/src/index.ts index 33c22a6ba43dc..07fc99522bc2e 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -43,7 +43,7 @@ const limit = pLimit(20); export class AbacService extends ServiceClass implements IAbacService { protected name = 'abac'; - private pdp!: IPolicyDecisionPoint; + private pdp: IPolicyDecisionPoint | null = null; private externalPdpConfig: ExternalPDPConfig = { baseUrl: '', @@ -58,7 +58,6 @@ export class AbacService extends ServiceClass implements IAbacService { constructor() { super(); - this.setPdpStrategy('local'); this.onSettingChanged('ABAC_PDP_Type', async ({ setting }): Promise => { const { value } = setting; @@ -98,6 +97,8 @@ export class AbacService extends ServiceClass implements IAbacService { } setPdpStrategy(strategy: 'local' | 'external'): void { + const previousPdp = this.pdp ? this.pdp.constructor.name : 'none'; + switch (strategy) { case 'external': this.pdp = new ExternalPDP({ ...this.externalPdpConfig }); @@ -107,6 +108,13 @@ export class AbacService extends ServiceClass implements IAbacService { this.pdp = new LocalPDP(); break; } + + logger.warn({ + msg: '>>> PDP STRATEGY CHANGED <<<', + from: previousPdp, + to: this.pdp.constructor.name, + requestedStrategy: strategy, + }); } override async started(): Promise { @@ -131,9 +139,7 @@ export class AbacService extends ServiceClass implements IAbacService { }; const pdpType = await Settings.get('ABAC_PDP_Type'); - if (pdpType === 'external') { - this.setPdpStrategy('external'); - } + this.setPdpStrategy(pdpType === 'external' ? 'external' : 'local'); } async addSubjectAttributes(user: IUser, ldapUser: ILDAPEntry, map: Record): Promise { @@ -573,6 +579,10 @@ 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; @@ -583,7 +593,13 @@ export class AbacService extends ServiceClass implements IAbacService { return true; } - const decision = await this.pdp.canAccessObject(room, user); + 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'); @@ -597,7 +613,7 @@ export class AbacService extends ServiceClass implements IAbacService { } async checkUsernamesMatchAttributes(usernames: string[], attributes: IAbacAttributeDefinition[], object: IRoom): Promise { - if (!usernames.length || !attributes.length) { + if (!usernames.length || !attributes.length || !this.pdp) { return; } @@ -639,6 +655,10 @@ export class AbacService extends ServiceClass implements IAbacService { return; } + if (!this.pdp) { + return; + } + try { const nonCompliantUsers = await this.pdp.onRoomAttributesChanged(room, newAttributes); @@ -657,7 +677,7 @@ 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; } @@ -678,6 +698,10 @@ export class AbacService extends ServiceClass implements IAbacService { } async evaluateRoomMembership(): Promise { + if (!this.pdp) { + return; + } + const abacRooms = await Rooms.find( { abacAttributes: { $exists: true, $ne: [] } }, { projection: { _id: 1, t: 1, teamMain: 1, abacAttributes: 1 } }, diff --git a/ee/packages/abac/src/pdp/ExternalPDP.ts b/ee/packages/abac/src/pdp/ExternalPDP.ts index 905e04f8a56fa..76cbc074dd3ea 100644 --- a/ee/packages/abac/src/pdp/ExternalPDP.ts +++ b/ee/packages/abac/src/pdp/ExternalPDP.ts @@ -173,13 +173,17 @@ export class ExternalPDP implements IPolicyDecisionPoint { const decision = result.decisionResponses?.[0]?.decision; pdpLogger.debug({ msg: 'GetDecisions response', userId: user._id, roomId: room._id, decision, fqns }); - const granted = decision === 'DECISION_PERMIT'; + if (decision === 'DECISION_PERMIT') { + return { granted: true }; + } - if (!granted) { + if (decision === 'DECISION_DENY') { return { granted: false, userToRemove: fullUser }; } - return { granted }; + // Unknown or missing decision — deny access but don't evict + pdpLogger.warn({ msg: 'Unexpected decision from external PDP', userId: user._id, roomId: room._id, decision }); + return { granted: false }; } async checkUsernamesMatchAttributes(usernames: string[], attributes: IAbacAttributeDefinition[], object: IRoom): Promise { 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; From d7925ff4b960e99163893d33b25ba0115e331048 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 24 Mar 2026 12:26:03 -0600 Subject: [PATCH 08/41] use tools --- apps/meteor/ee/server/configuration/abac.ts | 13 +-- ee/packages/abac/package.json | 1 + ee/packages/abac/src/index.ts | 84 +++++++++++-------- ee/packages/abac/src/pdp/ExternalPDP.ts | 6 +- .../model-typings/src/models/IUsersModel.ts | 2 + packages/models/src/models/Users.ts | 4 + yarn.lock | 2 + 7 files changed, 68 insertions(+), 44 deletions(-) diff --git a/apps/meteor/ee/server/configuration/abac.ts b/apps/meteor/ee/server/configuration/abac.ts index 16d91aa9fde30..dbdad3247377a 100644 --- a/apps/meteor/ee/server/configuration/abac.ts +++ b/apps/meteor/ee/server/configuration/abac.ts @@ -29,25 +29,20 @@ Meteor.startup(async () => { } }); - let lastSchedule: string; async function configureExternalPdpSync(): Promise { + if (await cronJobs.has(EXTERNAL_PDP_SYNC_JOB)) { + await cronJobs.remove(EXTERNAL_PDP_SYNC_JOB); + } + const abacEnabled = settings.get('ABAC_Enabled'); const pdpType = settings.get('ABAC_PDP_Type'); if (!abacEnabled || pdpType !== 'external') { - if (await cronJobs.has(EXTERNAL_PDP_SYNC_JOB)) { - await cronJobs.remove(EXTERNAL_PDP_SYNC_JOB); - } return; } const cronValue = settings.get('ABAC_External_Sync_Interval'); - if (cronValue !== lastSchedule && (await cronJobs.has(EXTERNAL_PDP_SYNC_JOB))) { - await cronJobs.remove(EXTERNAL_PDP_SYNC_JOB); - } - - lastSchedule = cronValue; await cronJobs.add(EXTERNAL_PDP_SYNC_JOB, cronValue, () => Abac.evaluateRoomMembership()); } diff --git a/ee/packages/abac/package.json b/ee/packages/abac/package.json index f2775ad092763..b31e3633136a8 100644 --- a/ee/packages/abac/package.json +++ b/ee/packages/abac/package.json @@ -32,6 +32,7 @@ "@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/index.ts b/ee/packages/abac/src/index.ts index 07fc99522bc2e..27dcdd82ed837 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'; @@ -74,25 +75,40 @@ export class AbacService extends ServiceClass implements IAbacService { this.decisionCacheTimeout = value; }); - const externalPdpSettingsMap: Record = { - ABAC_External_Base_URL: 'baseUrl', - ABAC_External_Client_ID: 'clientId', - ABAC_External_Client_Secret: 'clientSecret', - ABAC_External_OIDC_Endpoint: 'oidcEndpoint', - ABAC_External_Default_Entity_Key: 'defaultEntityKey', - ABAC_External_Attribute_Namespace: 'attributeNamespace', - }; + this.onSettingChanged('ABAC_External_Base_URL', async ({ setting }): Promise => { + this.externalPdpConfig.baseUrl = setting.value as string; + this.syncExternalPdpConfig(); + }); - for (const [settingId, configKey] of Object.entries(externalPdpSettingsMap)) { - this.onSettingChanged(settingId, async ({ setting }): Promise => { - const { value } = setting; - if (typeof value === 'string') { - this.externalPdpConfig[configKey] = value; - if (this.pdp instanceof ExternalPDP) { - this.pdp.updateConfig({ ...this.externalPdpConfig }); - } - } - }); + this.onSettingChanged('ABAC_External_Client_ID', async ({ setting }): Promise => { + this.externalPdpConfig.clientId = setting.value as string; + this.syncExternalPdpConfig(); + }); + + this.onSettingChanged('ABAC_External_Client_Secret', async ({ setting }): Promise => { + this.externalPdpConfig.clientSecret = setting.value as string; + this.syncExternalPdpConfig(); + }); + + this.onSettingChanged('ABAC_External_OIDC_Endpoint', async ({ setting }): Promise => { + this.externalPdpConfig.oidcEndpoint = setting.value as string; + this.syncExternalPdpConfig(); + }); + + this.onSettingChanged('ABAC_External_Default_Entity_Key', async ({ setting }): Promise => { + this.externalPdpConfig.defaultEntityKey = setting.value as string; + this.syncExternalPdpConfig(); + }); + + this.onSettingChanged('ABAC_External_Attribute_Namespace', async ({ setting }): Promise => { + this.externalPdpConfig.attributeNamespace = setting.value as string; + this.syncExternalPdpConfig(); + }); + } + + private syncExternalPdpConfig(): void { + if (this.pdp instanceof ExternalPDP) { + this.pdp.updateConfig({ ...this.externalPdpConfig }); } } @@ -109,8 +125,8 @@ export class AbacService extends ServiceClass implements IAbacService { break; } - logger.warn({ - msg: '>>> PDP STRATEGY CHANGED <<<', + logger.debug({ + msg: 'PDP strategy changed', from: previousPdp, to: this.pdp.constructor.name, requestedStrategy: strategy, @@ -120,6 +136,12 @@ export class AbacService extends ServiceClass implements IAbacService { override async started(): Promise { this.decisionCacheTimeout = await Settings.get('Abac_Cache_Decision_Time_Seconds'); + const pdpType = await Settings.get('ABAC_PDP_Type'); + if (pdpType !== 'external') { + this.setPdpStrategy('local'); + return; + } + const [baseUrl, clientId, clientSecret, oidcEndpoint, defaultEntityKey, attributeNamespace] = await Promise.all([ Settings.get('ABAC_External_Base_URL'), Settings.get('ABAC_External_Client_ID'), @@ -138,8 +160,7 @@ export class AbacService extends ServiceClass implements IAbacService { attributeNamespace: attributeNamespace || 'example.com', }; - const pdpType = await Settings.get('ABAC_PDP_Type'); - this.setPdpStrategy(pdpType === 'external' ? 'external' : 'local'); + this.setPdpStrategy('external'); } async addSubjectAttributes(user: IUser, ldapUser: ILDAPEntry, map: Record): Promise { @@ -702,10 +723,9 @@ export class AbacService extends ServiceClass implements IAbacService { return; } - const abacRooms = await Rooms.find( - { abacAttributes: { $exists: true, $ne: [] } }, - { projection: { _id: 1, t: 1, teamMain: 1, abacAttributes: 1 } }, - ).toArray(); + const abacRooms = await Rooms.findAllPrivateRoomsWithAbacAttributes({ + projection: { _id: 1, t: 1, teamMain: 1, abacAttributes: 1 }, + }).toArray(); if (!abacRooms.length) { return; @@ -714,13 +734,9 @@ export class AbacService extends ServiceClass implements IAbacService { const abacRoomById = Object.fromEntries(abacRooms.map((room) => [room._id, room])); const abacRoomIds = abacRooms.map((room) => room._id); - const users = Users.find( - { - active: true, - __rooms: { $in: abacRoomIds }, - }, - { projection: { _id: 1, emails: 1, username: 1, __rooms: 1 } }, - ); + const users = Users.findActiveByRoomIds(abacRoomIds, { + projection: { _id: 1, emails: 1, username: 1, __rooms: 1 }, + }); const entries = ( await users @@ -729,7 +745,7 @@ export class AbacService extends ServiceClass implements IAbacService { return rooms.length ? { user, rooms } : null; }) .toArray() - ).filter(Boolean) as Array<{ user: Pick; rooms: IRoom[] }>; + ).filter(isTruthy); if (!entries.length) { return; diff --git a/ee/packages/abac/src/pdp/ExternalPDP.ts b/ee/packages/abac/src/pdp/ExternalPDP.ts index 76cbc074dd3ea..65eed3c0e671c 100644 --- a/ee/packages/abac/src/pdp/ExternalPDP.ts +++ b/ee/packages/abac/src/pdp/ExternalPDP.ts @@ -1,6 +1,7 @@ import type { IAbacAttributeDefinition, IRoom, IUser, AtLeast } from '@rocket.chat/core-typings'; import { Rooms, Users, Subscriptions } from '@rocket.chat/models'; import { serverFetch } from '@rocket.chat/server-fetch'; +import { isTruthy } from '@rocket.chat/tools'; import { OnlyCompliantCanBeAddedToRoomError } from '../errors'; import { logger } from '../logger'; @@ -113,6 +114,7 @@ export class ExternalPDP implements IPolicyDecisionPoint { throw new Error('Default entity key is not configured for ExternalPDP'); } + // Maybe this should be more flexible and just allow a path? Something like `.emails.0.address` like the subject mapping from opentdf? switch (this.config.defaultEntityKey) { case 'emailAddress': return user.emails?.[0]?.address; @@ -215,7 +217,7 @@ export class ExternalPDP implements IPolicyDecisionPoint { ], }; }) - .filter(Boolean); + .filter(isTruthy); if (!decisionRequests.length) { throw new OnlyCompliantCanBeAddedToRoomError(); @@ -266,6 +268,7 @@ export class ExternalPDP implements IPolicyDecisionPoint { })) .filter((entry): entry is { user: IUser; entityKey: string } => !!entry.entityKey); + // For now: if no user is available we just assume they are not compliant if (!usersWithKeys.length) { return users; } @@ -276,6 +279,7 @@ export class ExternalPDP implements IPolicyDecisionPoint { entities: [this.buildEntityIdentifier(entityKey)], }, }, + // The subject mappings on opentdf should allow this action. Otherwise, this would be DENY action: { name: 'read' }, resources: [ { 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/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" From f891a5083283a6233bd76c1a489ed7d43580c4bf Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 24 Mar 2026 13:06:15 -0600 Subject: [PATCH 09/41] clearing up --- ee/packages/abac/src/pdp/ExternalPDP.ts | 304 +++++++++--------------- ee/packages/abac/src/pdp/index.ts | 3 +- ee/packages/abac/src/pdp/types.ts | 31 +++ 3 files changed, 149 insertions(+), 189 deletions(-) diff --git a/ee/packages/abac/src/pdp/ExternalPDP.ts b/ee/packages/abac/src/pdp/ExternalPDP.ts index 65eed3c0e671c..3728565b9faf2 100644 --- a/ee/packages/abac/src/pdp/ExternalPDP.ts +++ b/ee/packages/abac/src/pdp/ExternalPDP.ts @@ -1,28 +1,21 @@ import type { IAbacAttributeDefinition, IRoom, IUser, AtLeast } from '@rocket.chat/core-typings'; -import { Rooms, Users, Subscriptions } from '@rocket.chat/models'; +import { Rooms, Users } from '@rocket.chat/models'; import { serverFetch } from '@rocket.chat/server-fetch'; import { isTruthy } from '@rocket.chat/tools'; import { OnlyCompliantCanBeAddedToRoomError } from '../errors'; import { logger } from '../logger'; -import type { IPolicyDecisionPoint } from './types'; +import type { + IPolicyDecisionPoint, + IGetDecisionsResponse, + IGetDecisionBulkResponse, + IResourceDecision, + ITokenCache, + IExternalPDPConfig, +} from './types'; const pdpLogger = logger.section('ExternalPDP'); -export interface IExternalPDPConfig { - baseUrl: string; - clientId: string; - clientSecret: string; - oidcEndpoint: string; - defaultEntityKey: string; - attributeNamespace: string; -} - -interface ITokenCache { - accessToken: string; - expiresAt: number; -} - export class ExternalPDP implements IPolicyDecisionPoint { private tokenCache: ITokenCache | null = null; @@ -59,10 +52,10 @@ export class ExternalPDP implements IPolicyDecisionPoint { const data = (await response.json()) as { access_token: string; expires_in?: number }; - // Cache token with a safety margin of 30 seconds 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, }; @@ -91,6 +84,47 @@ export class ExternalPDP implements IPolicyDecisionPoint { return response.json() as Promise; } + private async getDecision(request: { + actions: unknown[]; + resourceAttributes: unknown[]; + entityChains: unknown[]; + }): 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 allResponses: Array<{ resourceDecisions?: IResourceDecision[] } | undefined> = []; + + for (let i = 0; i < requests.length; i += BATCH_SIZE) { + const batch = requests.slice(i, i + BATCH_SIZE); + const validBatch = batch.filter(Boolean); + + if (!validBatch.length) { + allResponses.push(...batch.map(() => undefined)); + continue; + } + + const result = await this.apiCall('/authorization.v2.AuthorizationService/GetDecisionBulk', { + decisionRequests: validBatch, + }); + + pdpLogger.debug({ msg: 'GetDecisionBulk response', batch: i + 1, result: result.decisionResponses }); + + const responses = result.decisionResponses ?? []; + let responseIdx = 0; + allResponses.push(...batch.map((req) => (req ? responses[responseIdx++] : undefined))); + } + + return allResponses; + } + private buildAttributeFqns(attributes: IAbacAttributeDefinition[]): string[] { if (!this.config.attributeNamespace) { throw new Error('Attribute namespace is not configured for ExternalPDP'); @@ -146,35 +180,22 @@ export class ExternalPDP implements IPolicyDecisionPoint { return { granted: false }; } - const fqns = this.buildAttributeFqns(attributes); - - const result = await this.apiCall<{ - decisionResponses?: Array<{ - decision?: string; - }>; - }>('/authorization.AuthorizationService/GetDecisions', { - decisionRequests: [ + const decision = await this.getDecision({ + actions: [{ standard: 1 }], + resourceAttributes: [ { - actions: [{ standard: 1 }], - resourceAttributes: [ - { - resourceAttributesId: room._id, - attributeValueFqns: fqns, - }, - ], - entityChains: [ - { - id: 'rc-access-check', - entities: [this.buildEntityIdentifier(entityKey)], - }, - ], + resourceAttributesId: room._id, + attributeValueFqns: this.buildAttributeFqns(attributes), + }, + ], + entityChains: [ + { + id: 'rc-access-check', + entities: [this.buildEntityIdentifier(entityKey)], }, ], }); - const decision = result.decisionResponses?.[0]?.decision; - pdpLogger.debug({ msg: 'GetDecisions response', userId: user._id, roomId: room._id, decision, fqns }); - if (decision === 'DECISION_PERMIT') { return { granted: true }; } @@ -183,8 +204,7 @@ export class ExternalPDP implements IPolicyDecisionPoint { return { granted: false, userToRemove: fullUser }; } - // Unknown or missing decision — deny access but don't evict - pdpLogger.warn({ msg: 'Unexpected decision from external PDP', userId: user._id, roomId: room._id, decision }); + // 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 }; } @@ -193,8 +213,9 @@ export class ExternalPDP implements IPolicyDecisionPoint { return; } - const users = await Users.find({ username: { $in: usernames } }, { projection: { _id: 1, emails: 1, username: 1 } }).toArray(); + const users = await Users.findByUsernames(usernames, { projection: { _id: 1, emails: 1, username: 1 } }).toArray(); + const fqns = this.buildAttributeFqns(attributes); const decisionRequests = users .map((user) => { const entityKey = this.getUserEntityKey(user); @@ -212,7 +233,7 @@ export class ExternalPDP implements IPolicyDecisionPoint { resources: [ { ephemeralId: object._id, - attributeValues: { fqns: this.buildAttributeFqns(attributes) }, + attributeValues: { fqns }, }, ], }; @@ -223,21 +244,9 @@ export class ExternalPDP implements IPolicyDecisionPoint { throw new OnlyCompliantCanBeAddedToRoomError(); } - const result = await this.apiCall<{ - decisionResponses?: Array<{ - resourceDecisions?: Array<{ - decision?: string; - }>; - }>; - }>('/authorization.v2.AuthorizationService/GetDecisionBulk', { - decisionRequests, - }); - - pdpLogger.debug({ msg: 'GetDecisionBulk response (checkUsernames)', roomId: object._id, result: result.decisionResponses }); + const responses = await this.getDecisionBulk(decisionRequests); - const hasNonCompliant = result.decisionResponses?.some((resp) => - resp.resourceDecisions?.some((rd) => rd.decision !== 'DECISION_PERMIT'), - ); + const hasNonCompliant = responses.some((resp) => resp?.resourceDecisions?.some((rd) => rd.decision !== 'DECISION_PERMIT')); if (hasNonCompliant) { throw new OnlyCompliantCanBeAddedToRoomError(); @@ -252,68 +261,52 @@ export class ExternalPDP implements IPolicyDecisionPoint { return []; } - const subscriptions = await Subscriptions.findByRoomId(room._id, { projection: { 'u._id': 1 } }).toArray(); - const userIds = subscriptions.map((s) => s.u._id); - - if (!userIds.length) { - return []; - } - - const users = await Users.find({ _id: { $in: userIds } }, { projection: { _id: 1, emails: 1, username: 1, __rooms: 1 } }).toArray(); + const users = Users.findActiveByRoomIds([room._id], { + projection: { _id: 1, emails: 1, username: 1 }, + }); - const usersWithKeys = users - .map((user) => ({ - user, - entityKey: this.getUserEntityKey(user), - })) - .filter((entry): entry is { user: IUser; entityKey: string } => !!entry.entityKey); + const nonCompliantUsers: IUser[] = []; + const decisionRequests: unknown[] = []; + const requestUserIndex: IUser[] = []; + const fqns = this.buildAttributeFqns(newAttributes); - // For now: if no user is available we just assume they are not compliant - if (!usersWithKeys.length) { - return users; - } + for await (const user of users) { + const entityKey = this.getUserEntityKey(user); + if (!entityKey) { + pdpLogger.warn({ msg: 'User has no entity key for external PDP evaluation, skipping', userId: user._id }); + continue; + } - const decisionRequests = usersWithKeys.map(({ entityKey }) => ({ - entityIdentifier: { - entityChain: { - entities: [this.buildEntityIdentifier(entityKey)], - }, - }, - // The subject mappings on opentdf should allow this action. Otherwise, this would be DENY - action: { name: 'read' }, - resources: [ - { - ephemeralId: room._id, - attributeValues: { fqns: this.buildAttributeFqns(newAttributes) }, + requestUserIndex.push(user); + decisionRequests.push({ + entityIdentifier: { + entityChain: { + entities: [this.buildEntityIdentifier(entityKey)], + }, }, - ], - })); - - const result = await this.apiCall<{ - decisionResponses?: Array<{ - resourceDecisions?: Array<{ - decision?: string; - }>; - }>; - }>('/authorization.v2.AuthorizationService/GetDecisionBulk', { - decisionRequests, - }); + action: { name: 'read' }, + resources: [ + { + ephemeralId: room._id, + attributeValues: { fqns }, + }, + ], + }); + } - pdpLogger.debug({ msg: 'GetDecisionBulk response (roomAttributesChanged)', roomId: room._id, result: result.decisionResponses }); + if (!decisionRequests.length) { + return nonCompliantUsers; + } - const nonCompliantUsers: IUser[] = []; + const responses = await this.getDecisionBulk(decisionRequests); - result.decisionResponses?.forEach((resp, index) => { - const permitted = resp.resourceDecisions?.every((rd) => rd.decision === 'DECISION_PERMIT'); - if (!permitted && usersWithKeys[index]) { - nonCompliantUsers.push(usersWithKeys[index].user); + responses.forEach((resp, index) => { + const permitted = resp?.resourceDecisions?.every((rd) => rd.decision === 'DECISION_PERMIT'); + if (!permitted && requestUserIndex[index]) { + nonCompliantUsers.push(requestUserIndex[index]); } }); - // Users without entity keys are also non-compliant - const usersWithoutKeys = users.filter((user) => !this.getUserEntityKey(user)); - nonCompliantUsers.push(...usersWithoutKeys); - return nonCompliantUsers; } @@ -329,10 +322,7 @@ export class ExternalPDP implements IPolicyDecisionPoint { for (const { user, rooms } of entries) { const entityKey = this.getUserEntityKey(user); if (!entityKey) { - for (const room of rooms) { - requestIndex.push({ user, room }); - allRequests.push(null); - } + pdpLogger.warn({ msg: 'User has no entity key for external PDP evaluation, skipping', userId: user._id }); continue; } @@ -359,52 +349,13 @@ export class ExternalPDP implements IPolicyDecisionPoint { return []; } - // Batch into chunks of 200 (GetDecisionBulk limit) - const BATCH_SIZE = 200; - const allDecisions: Array = []; - - for (let i = 0; i < allRequests.length; i += BATCH_SIZE) { - const batch = allRequests.slice(i, i + BATCH_SIZE); - const validBatch = batch.filter(Boolean); - - if (!validBatch.length) { - allDecisions.push(...batch.map(() => undefined)); - continue; - } - - const result = await this.apiCall<{ - decisionResponses?: Array<{ - resourceDecisions?: Array<{ - decision?: string; - }>; - }>; - }>('/authorization.v2.AuthorizationService/GetDecisionBulk', { - decisionRequests: validBatch, - }); - - pdpLogger.debug({ - msg: 'GetDecisionBulk response (evaluateUserRooms)', - batch: `${i}-${i + batch.length}`, - result: result.decisionResponses, - }); - - let resultIdx = 0; - for (const req of batch) { - if (!req) { - allDecisions.push(undefined); - } else { - const resp = result.decisionResponses?.[resultIdx]; - const permitted = resp?.resourceDecisions?.every((rd) => rd.decision === 'DECISION_PERMIT'); - allDecisions.push(permitted ? 'DECISION_PERMIT' : undefined); - resultIdx++; - } - } - } + const responses = await this.getDecisionBulk(allRequests); const nonCompliant: Array<{ user: Pick; room: IRoom }> = []; - allDecisions.forEach((decision, index) => { - if (decision !== 'DECISION_PERMIT' && requestIndex[index]) { + responses.forEach((resp, index) => { + const permitted = resp?.resourceDecisions?.every((rd) => rd.decision === 'DECISION_PERMIT'); + if (!permitted && requestIndex[index]) { nonCompliant.push({ user: requestIndex[index].user, room: requestIndex[index].room as IRoom }); } }); @@ -420,23 +371,13 @@ export class ExternalPDP implements IPolicyDecisionPoint { const entityKey = this.getUserEntityKey(user); if (!entityKey) { - // Without an entity key we cannot evaluate, treat all ABAC rooms as non-compliant - return Rooms.find( - { - _id: { $in: roomIds }, - abacAttributes: { $exists: true, $ne: [] }, - }, - { projection: { _id: 1 } }, - ).toArray(); + pdpLogger.warn({ msg: 'User has no entity key for external PDP evaluation, skipping', userId: user._id }); + return []; } - const abacRooms = await Rooms.find( - { - _id: { $in: roomIds }, - abacAttributes: { $exists: true, $ne: [] }, - }, - { projection: { _id: 1, abacAttributes: 1 } }, - ).toArray(); + const abacRooms = await Rooms.findPrivateRoomsByIdsWithAbacAttributes(roomIds, { + projection: { _id: 1, abacAttributes: 1 }, + }).toArray(); if (!abacRooms.length) { return []; @@ -457,23 +398,12 @@ export class ExternalPDP implements IPolicyDecisionPoint { ], })); - const result = await this.apiCall<{ - decisionResponses?: Array<{ - resourceDecisions?: Array<{ - ephemeralResourceId?: string; - decision?: string; - }>; - }>; - }>('/authorization.v2.AuthorizationService/GetDecisionBulk', { - decisionRequests, - }); - - pdpLogger.debug({ msg: 'GetDecisionBulk response (subjectAttributesChanged)', userId: user._id, result: result.decisionResponses }); + const responses = await this.getDecisionBulk(decisionRequests); const nonCompliantRooms: IRoom[] = []; - result.decisionResponses?.forEach((resp, index) => { - const permitted = resp.resourceDecisions?.every((rd) => rd.decision === 'DECISION_PERMIT'); + responses.forEach((resp, index) => { + const permitted = resp?.resourceDecisions?.every((rd) => rd.decision === 'DECISION_PERMIT'); if (!permitted && abacRooms[index]) { nonCompliantRooms.push(abacRooms[index]); } diff --git a/ee/packages/abac/src/pdp/index.ts b/ee/packages/abac/src/pdp/index.ts index 90446df1d7aa6..aad8c0cceffc7 100644 --- a/ee/packages/abac/src/pdp/index.ts +++ b/ee/packages/abac/src/pdp/index.ts @@ -1,4 +1,3 @@ export { LocalPDP } from './LocalPDP'; export { ExternalPDP } from './ExternalPDP'; -export type { IExternalPDPConfig as ExternalPDPConfig } from './ExternalPDP'; -export type { IPolicyDecisionPoint } from './types'; +export type { IExternalPDPConfig as ExternalPDPConfig, IPolicyDecisionPoint } from './types'; diff --git a/ee/packages/abac/src/pdp/types.ts b/ee/packages/abac/src/pdp/types.ts index 2cb43cc12fc0b..261b3c7390449 100644 --- a/ee/packages/abac/src/pdp/types.ts +++ b/ee/packages/abac/src/pdp/types.ts @@ -1,5 +1,22 @@ import type { IAbacAttributeDefinition, IRoom, IUser, AtLeast } from '@rocket.chat/core-typings'; +export interface IResourceDecision { + decision?: string; + ephemeralResourceId?: string; +} + +export interface IGetDecisionsResponse { + decisionResponses?: Array<{ + decision?: string; + }>; +} + +export interface IGetDecisionBulkResponse { + decisionResponses?: Array<{ + resourceDecisions?: IResourceDecision[]; + }>; +} + export interface IPolicyDecisionPoint { canAccessObject( room: AtLeast, @@ -22,3 +39,17 @@ export interface IPolicyDecisionPoint { }>, ): Promise; room: IRoom }>>; } + +export interface IExternalPDPConfig { + baseUrl: string; + clientId: string; + clientSecret: string; + oidcEndpoint: string; + defaultEntityKey: string; + attributeNamespace: string; +} + +export interface ITokenCache { + accessToken: string; + expiresAt: number; +} From e1a169b5f7559bb480a47a184f9bee9de5c8f91e Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 24 Mar 2026 13:49:23 -0600 Subject: [PATCH 10/41] fix --- apps/meteor/ee/server/lib/ldap/Manager.ts | 7 ++++++- ee/apps/authorization-service/Dockerfile | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/meteor/ee/server/lib/ldap/Manager.ts b/apps/meteor/ee/server/lib/ldap/Manager.ts index 5abc641f88a62..4615f8dd3b35a 100644 --- a/apps/meteor/ee/server/lib/ldap/Manager.ts +++ b/apps/meteor/ee/server/lib/ldap/Manager.ts @@ -130,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') || settings.get('ABAC_PDP_Type') === 'external') { + if ( + !settings.get('LDAP_Enable') || + !License.hasModule('abac') || + !settings.get('ABAC_Enabled') || + settings.get('ABAC_PDP_Type') === 'external' + ) { return; } diff --git a/ee/apps/authorization-service/Dockerfile b/ee/apps/authorization-service/Dockerfile index d26a97204b1f2..80293d68dd041 100644 --- a/ee/apps/authorization-service/Dockerfile +++ b/ee/apps/authorization-service/Dockerfile @@ -69,6 +69,9 @@ 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/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 From 3635526f1df202a428e04bd4542992a312ef1358 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 24 Mar 2026 14:04:38 -0600 Subject: [PATCH 11/41] missing pkg --- ee/apps/authorization-service/Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ee/apps/authorization-service/Dockerfile b/ee/apps/authorization-service/Dockerfile index 80293d68dd041..d238054966a16 100644 --- a/ee/apps/authorization-service/Dockerfile +++ b/ee/apps/authorization-service/Dockerfile @@ -69,6 +69,9 @@ 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 From 7975ce4bca03584619e1440f25e3ebffe71ae466 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 24 Mar 2026 14:53:27 -0600 Subject: [PATCH 12/41] argh --- packages/server-fetch/tsconfig.json | 1 - 1 file changed, 1 deletion(-) 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, From 212ab1d246906ddd6beecafb4bcc0a91e0dda407 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 25 Mar 2026 07:37:09 -0600 Subject: [PATCH 13/41] sec --- ee/packages/abac/src/pdp/ExternalPDP.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ee/packages/abac/src/pdp/ExternalPDP.ts b/ee/packages/abac/src/pdp/ExternalPDP.ts index 3728565b9faf2..74a7bd0b71e62 100644 --- a/ee/packages/abac/src/pdp/ExternalPDP.ts +++ b/ee/packages/abac/src/pdp/ExternalPDP.ts @@ -43,6 +43,7 @@ export class ExternalPDP implements IPolicyDecisionPoint { 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, }); @@ -72,6 +73,7 @@ export class ExternalPDP implements IPolicyDecisionPoint { '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, }); From 91bc3b02c063373ee40d37d794224ecfe36df9f8 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 25 Mar 2026 08:12:00 -0600 Subject: [PATCH 14/41] virtru --- apps/meteor/ee/server/configuration/abac.ts | 22 ++--- apps/meteor/ee/server/lib/ldap/Manager.ts | 4 +- apps/meteor/ee/server/settings/abac.ts | 60 +++++++------- ee/packages/abac/src/index.ts | 80 +++++++++---------- .../src/pdp/{ExternalPDP.ts => VirtruPDP.ts} | 30 +++---- ee/packages/abac/src/pdp/index.ts | 4 +- ee/packages/abac/src/pdp/types.ts | 2 +- .../src/ServerAudit/IAuditServerAbacAction.ts | 2 +- packages/i18n/src/locales/en.i18n.json | 30 +++---- 9 files changed, 117 insertions(+), 117 deletions(-) rename ee/packages/abac/src/pdp/{ExternalPDP.ts => VirtruPDP.ts} (91%) diff --git a/apps/meteor/ee/server/configuration/abac.ts b/apps/meteor/ee/server/configuration/abac.ts index dbdad3247377a..1dd4fad523216 100644 --- a/apps/meteor/ee/server/configuration/abac.ts +++ b/apps/meteor/ee/server/configuration/abac.ts @@ -7,7 +7,7 @@ import { Meteor } from 'meteor/meteor'; import { settings } from '../../../app/settings/server'; import { LDAPEE } from '../sdk'; -const EXTERNAL_PDP_SYNC_JOB = 'ABAC_External_PDP_Sync'; +const VIRTRU_PDP_SYNC_JOB = 'ABAC_Virtru_PDP_Sync'; Meteor.startup(async () => { let stopWatcher: () => void; @@ -24,36 +24,36 @@ Meteor.startup(async () => { await import('../hooks/abac'); stopWatcher = settings.watch('ABAC_Enabled', async (value) => { - if (value && settings.get('ABAC_PDP_Type') !== 'external') { + if (value && settings.get('ABAC_PDP_Type') !== 'virtru') { await LDAPEE.syncUsersAbacAttributes(Users.findLDAPUsers()); } }); - async function configureExternalPdpSync(): Promise { - if (await cronJobs.has(EXTERNAL_PDP_SYNC_JOB)) { - await cronJobs.remove(EXTERNAL_PDP_SYNC_JOB); + 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 !== 'external') { + if (!abacEnabled || pdpType !== 'virtru') { return; } - const cronValue = settings.get('ABAC_External_Sync_Interval'); + const cronValue = settings.get('ABAC_Virtru_Sync_Interval'); - await cronJobs.add(EXTERNAL_PDP_SYNC_JOB, cronValue, () => Abac.evaluateRoomMembership()); + await cronJobs.add(VIRTRU_PDP_SYNC_JOB, cronValue, () => Abac.evaluateRoomMembership()); } - stopCronWatcher = settings.watchMultiple(['ABAC_PDP_Type', 'ABAC_External_Sync_Interval'], () => configureExternalPdpSync()); + stopCronWatcher = settings.watchMultiple(['ABAC_PDP_Type', 'ABAC_Virtru_Sync_Interval'], () => configureVirtruPdpSync()); }, down: async () => { stopWatcher?.(); stopCronWatcher?.(); - if (await cronJobs.has(EXTERNAL_PDP_SYNC_JOB)) { - await cronJobs.remove(EXTERNAL_PDP_SYNC_JOB); + 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 4615f8dd3b35a..a5f36f6788467 100644 --- a/apps/meteor/ee/server/lib/ldap/Manager.ts +++ b/apps/meteor/ee/server/lib/ldap/Manager.ts @@ -110,7 +110,7 @@ export class LDAPEEManager extends LDAPManager { !settings.get('LDAP_Background_Sync_ABAC_Attributes') || !License.hasModule('abac') || !settings.get('ABAC_Enabled') || - settings.get('ABAC_PDP_Type') === 'external' + settings.get('ABAC_PDP_Type') === 'virtru' ) { return; } @@ -134,7 +134,7 @@ export class LDAPEEManager extends LDAPManager { !settings.get('LDAP_Enable') || !License.hasModule('abac') || !settings.get('ABAC_Enabled') || - settings.get('ABAC_PDP_Type') === 'external' + 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 0cd778851b7f8..9eaea35fc48f7 100644 --- a/apps/meteor/ee/server/settings/abac.ts +++ b/apps/meteor/ee/server/settings/abac.ts @@ -1,7 +1,7 @@ import { settingsRegistry } from '../../../app/settings/server'; const abacEnabledQuery = { _id: 'ABAC_Enabled', value: true }; -const externalPdpQuery = [abacEnabledQuery, { _id: 'ABAC_PDP_Type', value: 'external' }]; +const virtruPdpQuery = [abacEnabledQuery, { _id: 'ABAC_PDP_Type', value: 'virtru' }]; export function addSettings(): Promise { return settingsRegistry.addGroup('General', async function () { @@ -25,7 +25,7 @@ export function addSettings(): Promise { invalidValue: 'local', values: [ { key: 'local', i18nLabel: 'ABAC_PDP_Type_Local' }, - { key: 'external', i18nLabel: 'ABAC_PDP_Type_External' }, + { key: 'virtru', i18nLabel: 'ABAC_PDP_Type_Virtru' }, ], enableQuery: abacEnabledQuery, }); @@ -44,63 +44,63 @@ export function addSettings(): Promise { enableQuery: [abacEnabledQuery], }); - // External PDP Configuration - await this.add('ABAC_External_Base_URL', '', { + // Virtru PDP Configuration + await this.add('ABAC_Virtru_Base_URL', '', { type: 'string', public: false, invalidValue: '', - section: 'ABAC_External_PDP_Configuration', - enableQuery: externalPdpQuery, + section: 'ABAC_Virtru_PDP_Configuration', + enableQuery: virtruPdpQuery, }); - await this.add('ABAC_External_Client_ID', '', { + await this.add('ABAC_Virtru_Client_ID', '', { type: 'string', public: false, invalidValue: '', - section: 'ABAC_External_PDP_Configuration', - enableQuery: externalPdpQuery, + section: 'ABAC_Virtru_PDP_Configuration', + enableQuery: virtruPdpQuery, }); - await this.add('ABAC_External_Client_Secret', '', { + await this.add('ABAC_Virtru_Client_Secret', '', { type: 'password', public: false, invalidValue: '', - section: 'ABAC_External_PDP_Configuration', - enableQuery: externalPdpQuery, + section: 'ABAC_Virtru_PDP_Configuration', + enableQuery: virtruPdpQuery, }); - await this.add('ABAC_External_OIDC_Endpoint', '', { + await this.add('ABAC_Virtru_OIDC_Endpoint', '', { type: 'string', public: false, invalidValue: '', - section: 'ABAC_External_PDP_Configuration', - i18nDescription: 'ABAC_External_OIDC_Endpoint_Description', - enableQuery: externalPdpQuery, + section: 'ABAC_Virtru_PDP_Configuration', + i18nDescription: 'ABAC_Virtru_OIDC_Endpoint_Description', + enableQuery: virtruPdpQuery, }); - await this.add('ABAC_External_Default_Entity_Key', 'emailAddress', { + await this.add('ABAC_Virtru_Default_Entity_Key', 'emailAddress', { type: 'select', public: false, invalidValue: 'emailAddress', - section: 'ABAC_External_PDP_Configuration', - i18nDescription: 'ABAC_External_Default_Entity_Key_Description', + section: 'ABAC_Virtru_PDP_Configuration', + i18nDescription: 'ABAC_Virtru_Default_Entity_Key_Description', values: [ - { key: 'emailAddress', i18nLabel: 'ABAC_External_Entity_Key_Email' }, - { key: 'oidcIdentifier', i18nLabel: 'ABAC_External_Entity_Key_OIDC' }, + { key: 'emailAddress', i18nLabel: 'ABAC_Virtru_Entity_Key_Email' }, + { key: 'oidcIdentifier', i18nLabel: 'ABAC_Virtru_Entity_Key_OIDC' }, ], - enableQuery: externalPdpQuery, + enableQuery: virtruPdpQuery, }); - await this.add('ABAC_External_Attribute_Namespace', 'example.com', { + await this.add('ABAC_Virtru_Attribute_Namespace', 'example.com', { type: 'string', public: false, invalidValue: 'example.com', - section: 'ABAC_External_PDP_Configuration', - i18nDescription: 'ABAC_External_Attribute_Namespace_Description', - enableQuery: externalPdpQuery, + section: 'ABAC_Virtru_PDP_Configuration', + i18nDescription: 'ABAC_Virtru_Attribute_Namespace_Description', + enableQuery: virtruPdpQuery, }); - await this.add('ABAC_External_Sync_Interval', '*/5 * * * *', { + await this.add('ABAC_Virtru_Sync_Interval', '*/5 * * * *', { type: 'string', public: false, invalidValue: '*/5 * * * *', - section: 'ABAC_External_PDP_Configuration', - i18nDescription: 'ABAC_External_Sync_Interval_Description', - enableQuery: externalPdpQuery, + section: 'ABAC_Virtru_PDP_Configuration', + i18nDescription: 'ABAC_Virtru_Sync_Interval_Description', + enableQuery: virtruPdpQuery, }); }, ); diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 27dcdd82ed837..44a6db3083dab 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -35,8 +35,8 @@ import { MAX_ABAC_ATTRIBUTE_KEYS, } from './helper'; import { logger } from './logger'; -import type { IPolicyDecisionPoint, ExternalPDPConfig } 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); @@ -46,7 +46,7 @@ export class AbacService extends ServiceClass implements IAbacService { private pdp: IPolicyDecisionPoint | null = null; - private externalPdpConfig: ExternalPDPConfig = { + private virtruPdpConfig: VirtruPDPConfig = { baseUrl: '', clientId: '', clientSecret: '', @@ -62,7 +62,7 @@ export class AbacService extends ServiceClass implements IAbacService { this.onSettingChanged('ABAC_PDP_Type', async ({ setting }): Promise => { const { value } = setting; - if (value === 'local' || value === 'external') { + if (value === 'local' || value === 'virtru') { this.setPdpStrategy(value); } }); @@ -75,49 +75,49 @@ export class AbacService extends ServiceClass implements IAbacService { this.decisionCacheTimeout = value; }); - this.onSettingChanged('ABAC_External_Base_URL', async ({ setting }): Promise => { - this.externalPdpConfig.baseUrl = setting.value as string; - this.syncExternalPdpConfig(); + this.onSettingChanged('ABAC_Virtru_Base_URL', async ({ setting }): Promise => { + this.virtruPdpConfig.baseUrl = setting.value as string; + this.syncVirtruPdpConfig(); }); - this.onSettingChanged('ABAC_External_Client_ID', async ({ setting }): Promise => { - this.externalPdpConfig.clientId = setting.value as string; - this.syncExternalPdpConfig(); + this.onSettingChanged('ABAC_Virtru_Client_ID', async ({ setting }): Promise => { + this.virtruPdpConfig.clientId = setting.value as string; + this.syncVirtruPdpConfig(); }); - this.onSettingChanged('ABAC_External_Client_Secret', async ({ setting }): Promise => { - this.externalPdpConfig.clientSecret = setting.value as string; - this.syncExternalPdpConfig(); + this.onSettingChanged('ABAC_Virtru_Client_Secret', async ({ setting }): Promise => { + this.virtruPdpConfig.clientSecret = setting.value as string; + this.syncVirtruPdpConfig(); }); - this.onSettingChanged('ABAC_External_OIDC_Endpoint', async ({ setting }): Promise => { - this.externalPdpConfig.oidcEndpoint = setting.value as string; - this.syncExternalPdpConfig(); + this.onSettingChanged('ABAC_Virtru_OIDC_Endpoint', async ({ setting }): Promise => { + this.virtruPdpConfig.oidcEndpoint = setting.value as string; + this.syncVirtruPdpConfig(); }); - this.onSettingChanged('ABAC_External_Default_Entity_Key', async ({ setting }): Promise => { - this.externalPdpConfig.defaultEntityKey = setting.value as string; - this.syncExternalPdpConfig(); + this.onSettingChanged('ABAC_Virtru_Default_Entity_Key', async ({ setting }): Promise => { + this.virtruPdpConfig.defaultEntityKey = setting.value as string; + this.syncVirtruPdpConfig(); }); - this.onSettingChanged('ABAC_External_Attribute_Namespace', async ({ setting }): Promise => { - this.externalPdpConfig.attributeNamespace = setting.value as string; - this.syncExternalPdpConfig(); + this.onSettingChanged('ABAC_Virtru_Attribute_Namespace', async ({ setting }): Promise => { + this.virtruPdpConfig.attributeNamespace = setting.value as string; + this.syncVirtruPdpConfig(); }); } - private syncExternalPdpConfig(): void { - if (this.pdp instanceof ExternalPDP) { - this.pdp.updateConfig({ ...this.externalPdpConfig }); + private syncVirtruPdpConfig(): void { + if (this.pdp instanceof VirtruPDP) { + this.pdp.updateConfig({ ...this.virtruPdpConfig }); } } - setPdpStrategy(strategy: 'local' | 'external'): void { + setPdpStrategy(strategy: 'local' | 'virtru'): void { const previousPdp = this.pdp ? this.pdp.constructor.name : 'none'; switch (strategy) { - case 'external': - this.pdp = new ExternalPDP({ ...this.externalPdpConfig }); + case 'virtru': + this.pdp = new VirtruPDP({ ...this.virtruPdpConfig }); break; case 'local': default: @@ -137,21 +137,21 @@ export class AbacService extends ServiceClass implements IAbacService { this.decisionCacheTimeout = await Settings.get('Abac_Cache_Decision_Time_Seconds'); const pdpType = await Settings.get('ABAC_PDP_Type'); - if (pdpType !== 'external') { + if (pdpType !== 'virtru') { this.setPdpStrategy('local'); return; } const [baseUrl, clientId, clientSecret, oidcEndpoint, defaultEntityKey, attributeNamespace] = await Promise.all([ - Settings.get('ABAC_External_Base_URL'), - Settings.get('ABAC_External_Client_ID'), - Settings.get('ABAC_External_Client_Secret'), - Settings.get('ABAC_External_OIDC_Endpoint'), - Settings.get('ABAC_External_Default_Entity_Key'), - Settings.get('ABAC_External_Attribute_Namespace'), + 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.externalPdpConfig = { + this.virtruPdpConfig = { baseUrl: baseUrl || '', clientId: clientId || '', clientSecret: clientSecret || '', @@ -160,7 +160,7 @@ export class AbacService extends ServiceClass implements IAbacService { attributeNamespace: attributeNamespace || 'example.com', }; - this.setPdpStrategy('external'); + this.setPdpStrategy('virtru'); } async addSubjectAttributes(user: IUser, ldapUser: ILDAPEntry, map: Record): Promise { @@ -756,7 +756,7 @@ export class AbacService extends ServiceClass implements IAbacService { // TODO: this should be in a persistent queue await Promise.all( - nonCompliant.map(({ user, room }) => limit(() => this.removeUserFromRoom(room, user as IUser, 'external-pdp-sync'))), + 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 }); @@ -764,7 +764,7 @@ export class AbacService extends ServiceClass implements IAbacService { } } -export { LocalPDP, ExternalPDP } from './pdp'; -export type { IPolicyDecisionPoint, ExternalPDPConfig } from './pdp'; +export { LocalPDP, VirtruPDP } from './pdp'; +export type { IPolicyDecisionPoint, VirtruPDPConfig } from './pdp'; export default AbacService; diff --git a/ee/packages/abac/src/pdp/ExternalPDP.ts b/ee/packages/abac/src/pdp/VirtruPDP.ts similarity index 91% rename from ee/packages/abac/src/pdp/ExternalPDP.ts rename to ee/packages/abac/src/pdp/VirtruPDP.ts index 74a7bd0b71e62..d9b38f59b36b4 100644 --- a/ee/packages/abac/src/pdp/ExternalPDP.ts +++ b/ee/packages/abac/src/pdp/VirtruPDP.ts @@ -11,21 +11,21 @@ import type { IGetDecisionBulkResponse, IResourceDecision, ITokenCache, - IExternalPDPConfig, + IVirtruPDPConfig, } from './types'; -const pdpLogger = logger.section('ExternalPDP'); +const pdpLogger = logger.section('VirtruPDP'); -export class ExternalPDP implements IPolicyDecisionPoint { +export class VirtruPDP implements IPolicyDecisionPoint { private tokenCache: ITokenCache | null = null; - private config: IExternalPDPConfig; + private config: IVirtruPDPConfig; - constructor(config: IExternalPDPConfig) { + constructor(config: IVirtruPDPConfig) { this.config = config; } - updateConfig(config: IExternalPDPConfig): void { + updateConfig(config: IVirtruPDPConfig): void { this.config = config; this.tokenCache = null; } @@ -79,8 +79,8 @@ export class ExternalPDP implements IPolicyDecisionPoint { if (!response.ok) { const text = await response.text().catch(() => ''); - pdpLogger.error({ msg: 'External PDP API call failed', endpoint, status: response.status, response: text }); - throw new Error('External PDP call failed'); + 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; @@ -129,7 +129,7 @@ export class ExternalPDP implements IPolicyDecisionPoint { private buildAttributeFqns(attributes: IAbacAttributeDefinition[]): string[] { if (!this.config.attributeNamespace) { - throw new Error('Attribute namespace is not configured for ExternalPDP'); + throw new Error('Attribute namespace is not configured for VirtruPDP'); } return attributes.flatMap((attr) => @@ -147,7 +147,7 @@ export class ExternalPDP implements IPolicyDecisionPoint { private getUserEntityKey(user: Pick): string | undefined { if (!this.config.defaultEntityKey) { - throw new Error('Default entity key is not configured for ExternalPDP'); + throw new Error('Default entity key is not configured for VirtruPDP'); } // Maybe this should be more flexible and just allow a path? Something like `.emails.0.address` like the subject mapping from opentdf? @@ -157,7 +157,7 @@ export class ExternalPDP implements IPolicyDecisionPoint { case 'oidcIdentifier': return user.username; // For now, username, we're gonna change this to find the right oidc identifier for the user default: - throw new Error('Unsupported default entity key configuration for ExternalPDP'); + throw new Error('Unsupported default entity key configuration for VirtruPDP'); } } @@ -178,7 +178,7 @@ export class ExternalPDP implements IPolicyDecisionPoint { const entityKey = this.getUserEntityKey(fullUser); if (!entityKey) { - pdpLogger.warn({ msg: 'User has no entity key for external PDP evaluation', userId: user._id }); + pdpLogger.warn({ msg: 'User has no entity key for Virtru PDP evaluation', userId: user._id }); return { granted: false }; } @@ -275,7 +275,7 @@ export class ExternalPDP implements IPolicyDecisionPoint { for await (const user of users) { const entityKey = this.getUserEntityKey(user); if (!entityKey) { - pdpLogger.warn({ msg: 'User has no entity key for external PDP evaluation, skipping', userId: user._id }); + pdpLogger.warn({ msg: 'User has no entity key for Virtru PDP evaluation, skipping', userId: user._id }); continue; } @@ -324,7 +324,7 @@ export class ExternalPDP implements IPolicyDecisionPoint { for (const { user, rooms } of entries) { const entityKey = this.getUserEntityKey(user); if (!entityKey) { - pdpLogger.warn({ msg: 'User has no entity key for external PDP evaluation, skipping', userId: user._id }); + pdpLogger.warn({ msg: 'User has no entity key for Virtru PDP evaluation, skipping', userId: user._id }); continue; } @@ -373,7 +373,7 @@ export class ExternalPDP implements IPolicyDecisionPoint { const entityKey = this.getUserEntityKey(user); if (!entityKey) { - pdpLogger.warn({ msg: 'User has no entity key for external PDP evaluation, skipping', userId: user._id }); + pdpLogger.warn({ msg: 'User has no entity key for Virtru PDP evaluation, skipping', userId: user._id }); return []; } diff --git a/ee/packages/abac/src/pdp/index.ts b/ee/packages/abac/src/pdp/index.ts index aad8c0cceffc7..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 { IExternalPDPConfig as ExternalPDPConfig, 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 261b3c7390449..46a2625a584ae 100644 --- a/ee/packages/abac/src/pdp/types.ts +++ b/ee/packages/abac/src/pdp/types.ts @@ -40,7 +40,7 @@ export interface IPolicyDecisionPoint { ): Promise; room: IRoom }>>; } -export interface IExternalPDPConfig { +export interface IVirtruPDPConfig { baseUrl: string; clientId: string; clientSecret: string; diff --git a/packages/core-typings/src/ServerAudit/IAuditServerAbacAction.ts b/packages/core-typings/src/ServerAudit/IAuditServerAbacAction.ts index 0403997609f1a..af4b550257a02 100644 --- a/packages/core-typings/src/ServerAudit/IAuditServerAbacAction.ts +++ b/packages/core-typings/src/ServerAudit/IAuditServerAbacAction.ts @@ -3,7 +3,7 @@ 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' | 'external-pdp-sync'; +export type AbacAuditReason = 'ldap-sync' | 'room-attributes-change' | 'system' | 'api' | 'realtime-policy-eval' | 'virtru-pdp-sync'; export type AbacActionPerformed = 'revoked-object-access' | 'granted-object-access'; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 9466bb6a321b2..0761f6e91bfa8 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -17,23 +17,23 @@ "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_External": "External", + "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_External_PDP_Configuration": "External PDP Configuration", - "ABAC_External_Base_URL": "Base URL", - "ABAC_External_Client_ID": "Client ID", - "ABAC_External_Client_Secret": "Client Secret", - "ABAC_External_OIDC_Endpoint": "OIDC Endpoint", - "ABAC_External_OIDC_Endpoint_Description": "Base OIDC realm URL used for client credentials authentication (e.g. https://keycloak.example.com/auth/realms/opentdf).", - "ABAC_External_Default_Entity_Key": "Default Entity Key", - "ABAC_External_Default_Entity_Key_Description": "The `entity` identifier that will be sent to the ERS.", - "ABAC_External_Entity_Key_Email": "Email Address", - "ABAC_External_Entity_Key_OIDC": "OIDC Identifier", - "ABAC_External_Attribute_Namespace": "Attribute Namespace", - "ABAC_External_Attribute_Namespace_Description": "The namespace used to build attribute FQNs (e.g. opentdf.io produces https://opentdf.io/attr/{key}/value/{value}).", - "ABAC_External_Sync_Interval": "Membership Sync Interval", - "ABAC_External_Sync_Interval_Description": "Cron expression for how often to re-evaluate all ABAC room memberships against the external PDP and evict non-compliant users (e.g. */5 * * * * for every 5 minutes).", + "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 more", "ABAC_Learn_More": "Learn about ABAC", "ABAC_automatically_disabled_callout": "ABAC automatically disabled", From c33351bf7c97b7a86f9fcdc1c186e3929f58498f Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 25 Mar 2026 08:16:27 -0600 Subject: [PATCH 15/41] pdp --- ee/packages/abac/src/audit.ts | 3 +++ ee/packages/abac/src/index.ts | 15 ++++++++++++++- .../src/ServerAudit/IAuditServerAbacAction.ts | 3 +++ 3 files changed, 20 insertions(+), 1 deletion(-) 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/index.ts b/ee/packages/abac/src/index.ts index 44a6db3083dab..1db058471c9c6 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -645,12 +645,25 @@ export class AbacService extends ServiceClass implements IAbacService { }); } + private get pdpType(): 'local' | 'virtru' { + return this.pdp instanceof VirtruPDP ? 'virtru' : 'local'; + } + 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', diff --git a/packages/core-typings/src/ServerAudit/IAuditServerAbacAction.ts b/packages/core-typings/src/ServerAudit/IAuditServerAbacAction.ts index af4b550257a02..f044a1c375de6 100644 --- a/packages/core-typings/src/ServerAudit/IAuditServerAbacAction.ts +++ b/packages/core-typings/src/ServerAudit/IAuditServerAbacAction.ts @@ -5,6 +5,8 @@ export type MinimalRoom = Pick; 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'; export type AbacAttributeDefinitionChangeType = @@ -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'; } From 419cfee60288a33b8bc2f9374c7ddfe95c0ed9b4 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 25 Mar 2026 08:36:07 -0600 Subject: [PATCH 16/41] plain field --- ee/packages/abac/src/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 1db058471c9c6..2d0d0bde38425 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -118,10 +118,12 @@ export class AbacService extends ServiceClass implements IAbacService { switch (strategy) { case 'virtru': this.pdp = new VirtruPDP({ ...this.virtruPdpConfig }); + this.pdpType = 'virtru'; break; case 'local': default: this.pdp = new LocalPDP(); + this.pdpType = 'local'; break; } @@ -645,9 +647,7 @@ export class AbacService extends ServiceClass implements IAbacService { }); } - private get pdpType(): 'local' | 'virtru' { - return this.pdp instanceof VirtruPDP ? 'virtru' : 'local'; - } + private pdpType: 'local' | 'virtru' = 'local'; private async removeUserFromRoom(room: AtLeast, user: IUser, reason: AbacAuditReason): Promise { return Room.removeUserFromRoom(room._id, user, { From d9e184583b8e877353b5dd31b1e0e05396f52517 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 25 Mar 2026 08:54:25 -0600 Subject: [PATCH 17/41] virtru --- ee/packages/abac/src/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 2d0d0bde38425..d2a4f98ebc8f4 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -768,9 +768,7 @@ export class AbacService extends ServiceClass implements IAbacService { 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'))), - ); + 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 }); } From 375aa55e250c89c7e8392b79258857402be0d564 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 25 Mar 2026 12:17:29 -0600 Subject: [PATCH 18/41] remove trailing slashes --- ee/packages/abac/src/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index d2a4f98ebc8f4..e837081c958cd 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -76,7 +76,7 @@ export class AbacService extends ServiceClass implements IAbacService { }); this.onSettingChanged('ABAC_Virtru_Base_URL', async ({ setting }): Promise => { - this.virtruPdpConfig.baseUrl = setting.value as string; + this.virtruPdpConfig.baseUrl = (setting.value as string).replace(/\/+$/, ''); this.syncVirtruPdpConfig(); }); @@ -91,7 +91,7 @@ export class AbacService extends ServiceClass implements IAbacService { }); this.onSettingChanged('ABAC_Virtru_OIDC_Endpoint', async ({ setting }): Promise => { - this.virtruPdpConfig.oidcEndpoint = setting.value as string; + this.virtruPdpConfig.oidcEndpoint = (setting.value as string).replace(/\/+$/, ''); this.syncVirtruPdpConfig(); }); @@ -154,10 +154,10 @@ export class AbacService extends ServiceClass implements IAbacService { ]); this.virtruPdpConfig = { - baseUrl: baseUrl || '', + baseUrl: (baseUrl || '').replace(/\/+$/, ''), clientId: clientId || '', clientSecret: clientSecret || '', - oidcEndpoint: oidcEndpoint || '', + oidcEndpoint: (oidcEndpoint || '').replace(/\/+$/, ''), defaultEntityKey: defaultEntityKey || 'emailAddress', attributeNamespace: attributeNamespace || 'example.com', }; From 0e49080d75081450a044de5dd44b1042975a75eb Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 26 Mar 2026 12:09:17 -0600 Subject: [PATCH 19/41] fix types --- ee/packages/abac/src/pdp/VirtruPDP.ts | 21 +++++++++-------- ee/packages/abac/src/pdp/types.ts | 33 +++++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/ee/packages/abac/src/pdp/VirtruPDP.ts b/ee/packages/abac/src/pdp/VirtruPDP.ts index d9b38f59b36b4..3404f4667d652 100644 --- a/ee/packages/abac/src/pdp/VirtruPDP.ts +++ b/ee/packages/abac/src/pdp/VirtruPDP.ts @@ -6,7 +6,10 @@ import { isTruthy } from '@rocket.chat/tools'; import { OnlyCompliantCanBeAddedToRoomError } from '../errors'; import { logger } from '../logger'; import type { + IEntityIdentifier, IPolicyDecisionPoint, + IGetDecisionRequest, + IGetDecisionBulkRequest, IGetDecisionsResponse, IGetDecisionBulkResponse, IResourceDecision, @@ -86,11 +89,7 @@ export class VirtruPDP implements IPolicyDecisionPoint { return response.json() as Promise; } - private async getDecision(request: { - actions: unknown[]; - resourceAttributes: unknown[]; - entityChains: unknown[]; - }): Promise { + private async getDecision(request: IGetDecisionRequest): Promise { const result = await this.apiCall('/authorization.AuthorizationService/GetDecisions', { decisionRequests: [request], }); @@ -100,7 +99,9 @@ export class VirtruPDP implements IPolicyDecisionPoint { return result.decisionResponses?.[0]?.decision; } - private async getDecisionBulk(requests: Array): Promise> { + private async getDecisionBulk( + requests: Array, + ): Promise> { const BATCH_SIZE = 200; const allResponses: Array<{ resourceDecisions?: IResourceDecision[] } | undefined> = []; @@ -117,7 +118,7 @@ export class VirtruPDP implements IPolicyDecisionPoint { decisionRequests: validBatch, }); - pdpLogger.debug({ msg: 'GetDecisionBulk response', batch: i + 1, result: result.decisionResponses }); + pdpLogger.debug({ msg: 'GetDecisionBulk response', batch: i + 1, result }); const responses = result.decisionResponses ?? []; let responseIdx = 0; @@ -137,7 +138,7 @@ export class VirtruPDP implements IPolicyDecisionPoint { ); } - private buildEntityIdentifier(entityKey: string) { + private buildEntityIdentifier(entityKey: string): IEntityIdentifier { if (this.config.defaultEntityKey === 'emailAddress') { return { emailAddress: entityKey }; } @@ -268,7 +269,7 @@ export class VirtruPDP implements IPolicyDecisionPoint { }); const nonCompliantUsers: IUser[] = []; - const decisionRequests: unknown[] = []; + const decisionRequests: IGetDecisionBulkRequest[] = []; const requestUserIndex: IUser[] = []; const fqns = this.buildAttributeFqns(newAttributes); @@ -319,7 +320,7 @@ export class VirtruPDP implements IPolicyDecisionPoint { }>, ): Promise; room: IRoom }>> { const requestIndex: Array<{ user: Pick; room: AtLeast }> = []; - const allRequests: unknown[] = []; + const allRequests: IGetDecisionBulkRequest[] = []; for (const { user, rooms } of entries) { const entityKey = this.getUserEntityKey(user); diff --git a/ee/packages/abac/src/pdp/types.ts b/ee/packages/abac/src/pdp/types.ts index 46a2625a584ae..8e877f56b09e4 100644 --- a/ee/packages/abac/src/pdp/types.ts +++ b/ee/packages/abac/src/pdp/types.ts @@ -1,13 +1,42 @@ 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_UNKNOWN'; + export interface IResourceDecision { - decision?: string; + decision?: Decision; ephemeralResourceId?: string; } export interface IGetDecisionsResponse { decisionResponses?: Array<{ - decision?: string; + decision?: Decision; }>; } From dd21ef3542cf80303259cabc5ec6cf5405f2a44c Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 26 Mar 2026 12:10:50 -0600 Subject: [PATCH 20/41] types again --- ee/packages/abac/src/index.ts | 4 ++-- ee/packages/abac/src/pdp/types.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index e837081c958cd..d3f35626626b7 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -96,7 +96,7 @@ export class AbacService extends ServiceClass implements IAbacService { }); this.onSettingChanged('ABAC_Virtru_Default_Entity_Key', async ({ setting }): Promise => { - this.virtruPdpConfig.defaultEntityKey = setting.value as string; + this.virtruPdpConfig.defaultEntityKey = setting.value as VirtruPDPConfig['defaultEntityKey']; this.syncVirtruPdpConfig(); }); @@ -158,7 +158,7 @@ export class AbacService extends ServiceClass implements IAbacService { clientId: clientId || '', clientSecret: clientSecret || '', oidcEndpoint: (oidcEndpoint || '').replace(/\/+$/, ''), - defaultEntityKey: defaultEntityKey || 'emailAddress', + defaultEntityKey: (defaultEntityKey as VirtruPDPConfig['defaultEntityKey']) || 'emailAddress', attributeNamespace: attributeNamespace || 'example.com', }; diff --git a/ee/packages/abac/src/pdp/types.ts b/ee/packages/abac/src/pdp/types.ts index 8e877f56b09e4..9c6612181577a 100644 --- a/ee/packages/abac/src/pdp/types.ts +++ b/ee/packages/abac/src/pdp/types.ts @@ -74,7 +74,7 @@ export interface IVirtruPDPConfig { clientId: string; clientSecret: string; oidcEndpoint: string; - defaultEntityKey: string; + defaultEntityKey: 'emailAddress' | 'oidcIdentifier'; attributeNamespace: string; } From b390b19bf81a267bb30ae6498a6e1d1203ba3cbe Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 26 Mar 2026 12:58:36 -0600 Subject: [PATCH 21/41] types & promise queue --- ee/packages/abac/src/pdp/VirtruPDP.ts | 51 ++++++++++++++------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/ee/packages/abac/src/pdp/VirtruPDP.ts b/ee/packages/abac/src/pdp/VirtruPDP.ts index 3404f4667d652..afd17f8726e37 100644 --- a/ee/packages/abac/src/pdp/VirtruPDP.ts +++ b/ee/packages/abac/src/pdp/VirtruPDP.ts @@ -2,10 +2,12 @@ import type { IAbacAttributeDefinition, IRoom, IUser, AtLeast } from '@rocket.ch 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 { OnlyCompliantCanBeAddedToRoomError } from '../errors'; import { logger } from '../logger'; import type { + Decision, IEntityIdentifier, IPolicyDecisionPoint, IGetDecisionRequest, @@ -37,7 +39,6 @@ export class VirtruPDP implements IPolicyDecisionPoint { 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', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, @@ -89,7 +90,7 @@ export class VirtruPDP implements IPolicyDecisionPoint { return response.json() as Promise; } - private async getDecision(request: IGetDecisionRequest): Promise { + private async getDecision(request: IGetDecisionRequest): Promise { const result = await this.apiCall('/authorization.AuthorizationService/GetDecisions', { decisionRequests: [request], }); @@ -103,29 +104,36 @@ export class VirtruPDP implements IPolicyDecisionPoint { requests: Array, ): Promise> { const BATCH_SIZE = 200; - const allResponses: Array<{ resourceDecisions?: IResourceDecision[] } | undefined> = []; + const limit = pLimit(4); + const batches: Array<(IGetDecisionBulkRequest | null)[]> = []; for (let i = 0; i < requests.length; i += BATCH_SIZE) { - const batch = requests.slice(i, i + BATCH_SIZE); - const validBatch = batch.filter(Boolean); + batches.push(requests.slice(i, i + BATCH_SIZE)); + } - if (!validBatch.length) { - allResponses.push(...batch.map(() => undefined)); - continue; - } + const batchResults = await Promise.all( + batches.map((batch, batchIndex) => + limit(async (): Promise> => { + const validBatch = batch.filter(Boolean); - const result = await this.apiCall('/authorization.v2.AuthorizationService/GetDecisionBulk', { - decisionRequests: validBatch, - }); + if (!validBatch.length) { + return batch.map(() => undefined); + } - pdpLogger.debug({ msg: 'GetDecisionBulk response', batch: i + 1, result }); + const result = await this.apiCall('/authorization.v2.AuthorizationService/GetDecisionBulk', { + decisionRequests: validBatch, + }); - const responses = result.decisionResponses ?? []; - let responseIdx = 0; - allResponses.push(...batch.map((req) => (req ? responses[responseIdx++] : undefined))); - } + 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 allResponses; + return batchResults.flat(); } private buildAttributeFqns(attributes: IAbacAttributeDefinition[]): string[] { @@ -147,18 +155,11 @@ export class VirtruPDP implements IPolicyDecisionPoint { } private getUserEntityKey(user: Pick): string | undefined { - if (!this.config.defaultEntityKey) { - throw new Error('Default entity key is not configured for VirtruPDP'); - } - - // Maybe this should be more flexible and just allow a path? Something like `.emails.0.address` like the subject mapping from opentdf? 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 - default: - throw new Error('Unsupported default entity key configuration for VirtruPDP'); } } From e27da5265351fc957156b9a57455ab4a7f808ffe Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 26 Mar 2026 13:28:13 -0600 Subject: [PATCH 22/41] availability --- apps/meteor/ee/server/api/abac/index.ts | 17 ++++++ apps/meteor/ee/server/api/abac/schemas.ts | 10 ++++ ee/packages/abac/src/index.ts | 8 +++ ee/packages/abac/src/pdp/LocalPDP.ts | 4 ++ ee/packages/abac/src/pdp/VirtruPDP.ts | 53 +++++++++++++++++++ ee/packages/abac/src/pdp/types.ts | 2 + .../core-services/src/types/IAbacService.ts | 1 + 7 files changed, 95 insertions(+) diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index 0ed503678f527..68c46890a2a43 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -22,6 +22,7 @@ import { GETAbacRoomsResponseValidator, GETAbacAuditEventsQuerySchema, GETAbacAuditEventsResponseSchema, + GETAbacPdpHealthResponseSchema, } from './schemas'; import { API } from '../../../../app/api/server'; import type { ExtractRoutesFromAPI } from '../../../../app/api/server/ApiClass'; @@ -357,6 +358,22 @@ const abacEndpoints = API.v1 return API.v1.success(result); }, ) + .get( + 'abac/pdp/health', + { + authRequired: true, + permissionsRequired: ['abac-management'], + response: { + 200: GETAbacPdpHealthResponseSchema, + 401: validateUnauthorizedErrorResponse, + 403: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const available = await Abac.isPdpAvailable(); + return API.v1.success({ available }); + }, + ) .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..34cdca8992ca5 100644 --- a/apps/meteor/ee/server/api/abac/schemas.ts +++ b/apps/meteor/ee/server/api/abac/schemas.ts @@ -342,6 +342,16 @@ export const POSTAbacUsersSyncBodySchema = ajv.compile<{ export const GenericErrorSchema = ajv.compile<{ success: boolean; message: string }>(GenericError); +export const GETAbacPdpHealthResponseSchema = ajv.compile<{ available: boolean }>({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + available: { type: 'boolean' }, + }, + required: ['available'], + additionalProperties: false, +}); + const GETAbacRoomsListQuerySchema = { type: 'object', properties: { diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index d3f35626626b7..8f5847b7b8c1d 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -731,6 +731,14 @@ export class AbacService extends ServiceClass implements IAbacService { } } + async isPdpAvailable(): Promise { + if (!this.pdp) { + return false; + } + + return this.pdp.isAvailable(); + } + async evaluateRoomMembership(): Promise { if (!this.pdp) { return; diff --git a/ee/packages/abac/src/pdp/LocalPDP.ts b/ee/packages/abac/src/pdp/LocalPDP.ts index 5d5db6a899c75..da62356f4bab0 100644 --- a/ee/packages/abac/src/pdp/LocalPDP.ts +++ b/ee/packages/abac/src/pdp/LocalPDP.ts @@ -6,6 +6,10 @@ import { buildCompliantConditions, buildNonCompliantConditions, buildRoomNonComp import type { IPolicyDecisionPoint } from './types'; export class LocalPDP implements IPolicyDecisionPoint { + async isAvailable(): Promise { + return true; + } + async canAccessObject( room: AtLeast, user: AtLeast, diff --git a/ee/packages/abac/src/pdp/VirtruPDP.ts b/ee/packages/abac/src/pdp/VirtruPDP.ts index afd17f8726e37..6031c4acbfb68 100644 --- a/ee/packages/abac/src/pdp/VirtruPDP.ts +++ b/ee/packages/abac/src/pdp/VirtruPDP.ts @@ -35,6 +35,28 @@ export class VirtruPDP implements IPolicyDecisionPoint { this.tokenCache = null; } + async isAvailable(): Promise { + try { + const response = await serverFetch(`${this.config.baseUrl}/healthz`, { + method: 'GET', + // 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; + } + } + private async getClientToken(): Promise { if (this.tokenCache && Date.now() < this.tokenCache.expiresAt) { return this.tokenCache.accessToken; @@ -173,6 +195,11 @@ export class VirtruPDP implements IPolicyDecisionPoint { return { granted: true }; } + if (!(await this.isAvailable())) { + pdpLogger.warn({ msg: 'Virtru PDP is unavailable, failing closed', roomId: room._id, userId: user._id }); + return { granted: false }; + } + const fullUser = await Users.findOneById(user._id); if (!fullUser) { return { granted: false }; @@ -217,6 +244,11 @@ export class VirtruPDP implements IPolicyDecisionPoint { return; } + if (!(await this.isAvailable())) { + pdpLogger.warn({ msg: 'Virtru PDP is unavailable, failing closed — refusing to add users', roomId: object._id }); + throw new OnlyCompliantCanBeAddedToRoomError(); + } + const users = await Users.findByUsernames(usernames, { projection: { _id: 1, emails: 1, username: 1 } }).toArray(); const fqns = this.buildAttributeFqns(attributes); @@ -265,6 +297,14 @@ export class VirtruPDP implements IPolicyDecisionPoint { return []; } + if (!(await this.isAvailable())) { + pdpLogger.warn({ + msg: 'Virtru PDP is unavailable, skipping room attributes evaluation — no users will be removed', + roomId: room._id, + }); + return []; + } + const users = Users.findActiveByRoomIds([room._id], { projection: { _id: 1, emails: 1, username: 1 }, }); @@ -320,6 +360,11 @@ export class VirtruPDP implements IPolicyDecisionPoint { rooms: AtLeast[]; }>, ): Promise; room: IRoom }>> { + if (!(await this.isAvailable())) { + pdpLogger.warn({ msg: 'Virtru PDP is unavailable, skipping bulk room membership evaluation — no users will be removed' }); + return []; + } + const requestIndex: Array<{ user: Pick; room: AtLeast }> = []; const allRequests: IGetDecisionBulkRequest[] = []; @@ -373,6 +418,14 @@ export class VirtruPDP implements IPolicyDecisionPoint { return []; } + if (!(await this.isAvailable())) { + pdpLogger.warn({ + msg: 'Virtru PDP is unavailable, skipping subject attributes evaluation — no users will be removed', + userId: user._id, + }); + return []; + } + const entityKey = this.getUserEntityKey(user); if (!entityKey) { pdpLogger.warn({ msg: 'User has no entity key for Virtru PDP evaluation, skipping', userId: user._id }); diff --git a/ee/packages/abac/src/pdp/types.ts b/ee/packages/abac/src/pdp/types.ts index 9c6612181577a..413c51d05cc33 100644 --- a/ee/packages/abac/src/pdp/types.ts +++ b/ee/packages/abac/src/pdp/types.ts @@ -47,6 +47,8 @@ export interface IGetDecisionBulkResponse { } export interface IPolicyDecisionPoint { + isAvailable(): Promise; + canAccessObject( room: AtLeast, user: AtLeast, diff --git a/packages/core-services/src/types/IAbacService.ts b/packages/core-services/src/types/IAbacService.ts index 2ff09cc489488..ab111a32ae49f 100644 --- a/packages/core-services/src/types/IAbacService.ts +++ b/packages/core-services/src/types/IAbacService.ts @@ -47,4 +47,5 @@ export interface IAbacService { ): Promise; addSubjectAttributes(user: IUser, ldapUser: ILDAPEntry, map: Record, actor: AbacActor | undefined): Promise; evaluateRoomMembership(): Promise; + isPdpAvailable(): Promise; } From a84cb10b965bac96b7706ab185fa907a1a69f7be Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 26 Mar 2026 15:51:08 -0600 Subject: [PATCH 23/41] Revert "fix: Calendar status event status switch (#39491)" This reverts commit b29fb86f7c255353d09e29822ede132a6cf2c5c2. --- .../server/services/calendar/service.ts | 14 ++--- .../statusEvents/applyStatusChange.ts | 58 +++++++------------ apps/meteor/tests/end-to-end/api/calendar.ts | 39 ------------- 3 files changed, 27 insertions(+), 84 deletions(-) diff --git a/apps/meteor/server/services/calendar/service.ts b/apps/meteor/server/services/calendar/service.ts index 853ecab6c95a2..d41f80a55de3e 100644 --- a/apps/meteor/server/services/calendar/service.ts +++ b/apps/meteor/server/services/calendar/service.ts @@ -285,15 +285,15 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe return; } - const user = await Users.findOneById(event.uid, { projection: { statusDefault: 1 } }); - if (!user || user.statusDefault === UserStatus.OFFLINE) { + const user = await Users.findOneById(event.uid, { projection: { status: 1 } }); + if (!user || user.status === UserStatus.OFFLINE) { return; } const overlappingEvents = await CalendarEvent.findOverlappingEvents(event._id, event.uid, event.startTime, event.endTime) .sort({ startTime: -1 }) .toArray(); - const previousStatus = overlappingEvents.at(0)?.previousStatus ?? user.statusDefault; + const previousStatus = overlappingEvents.at(0)?.previousStatus ?? user.status; if (previousStatus) { await CalendarEvent.updateEvent(event._id, { previousStatus }); @@ -313,16 +313,16 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe return; } - const user = await Users.findOneById(event.uid, { projection: { statusDefault: 1 } }); + const user = await Users.findOneById(event.uid, { projection: { status: 1 } }); if (!user) { return; } // Only restore status if: - // 1. The current statusDefault is BUSY (meaning it was set by our system, not manually changed by user) + // 1. The current status is BUSY (meaning it was set by our system, not manually changed by user) // 2. We have a previousStatus stored from before the event started - if (user.statusDefault === UserStatus.BUSY && event.previousStatus && event.previousStatus !== user.statusDefault) { + if (user.status === UserStatus.BUSY && event.previousStatus && event.previousStatus !== user.status) { await applyStatusChange({ eventId: event._id, uid: event.uid, @@ -334,7 +334,7 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe logger.debug({ msg: 'Not restoring status for user', userId: event.uid, - currentStatusDefault: user.statusDefault, + currentStatus: user.status, previousStatus: event.previousStatus, }); } diff --git a/apps/meteor/server/services/calendar/statusEvents/applyStatusChange.ts b/apps/meteor/server/services/calendar/statusEvents/applyStatusChange.ts index 83ef6d78a0f6b..251af36abadd6 100644 --- a/apps/meteor/server/services/calendar/statusEvents/applyStatusChange.ts +++ b/apps/meteor/server/services/calendar/statusEvents/applyStatusChange.ts @@ -20,51 +20,33 @@ export async function applyStatusChange({ status?: UserStatus; shouldScheduleRemoval?: boolean; }): Promise { - const user = await Users.findOneById(uid, { projection: { roles: 1, username: 1, name: 1, statusDefault: 1 } }); - - if (!user || user.statusDefault === UserStatus.OFFLINE) { - logger.debug({ - msg: 'Cannot apply status change for event, user is offline or does not exist', - eventId, - uid, - }); - - return; - } - - const newStatus = status ?? UserStatus.BUSY; - const previousStatus = user.statusDefault; - logger.debug({ msg: 'Applying status change for event', eventId, - user: { _id: uid, statusDefault: user?.statusDefault }, + uid, startTime, endTime, - newStatus, - previousStatus, + status: status ?? UserStatus.BUSY, }); - let statusChanged = false; - - if (newStatus === UserStatus.BUSY) { - await Users.updateStatusAndStatusDefault(uid, newStatus, newStatus); - statusChanged = true; - } else if (user.statusDefault === UserStatus.BUSY) { - await Users.updateStatusAndStatusDefault(uid, newStatus, newStatus); - statusChanged = true; + const user = await Users.findOneById(uid, { projection: { roles: 1, username: 1, name: 1, status: 1 } }); + if (!user || user.status === UserStatus.OFFLINE) { + return; } - if (statusChanged) { - await api.broadcast('presence.status', { - user: { - status: newStatus, - _id: uid, - roles: user.roles, - username: user.username, - name: user.name, - }, - previousStatus, - }); - } + const newStatus = status ?? UserStatus.BUSY; + const previousStatus = user.status; + + await Users.updateStatusAndStatusDefault(uid, newStatus, newStatus); + + await api.broadcast('presence.status', { + user: { + status: newStatus, + _id: uid, + roles: user.roles, + username: user.username, + name: user.name, + }, + previousStatus, + }); } diff --git a/apps/meteor/tests/end-to-end/api/calendar.ts b/apps/meteor/tests/end-to-end/api/calendar.ts index c15e26b9948d6..3ce9cb4159cb5 100644 --- a/apps/meteor/tests/end-to-end/api/calendar.ts +++ b/apps/meteor/tests/end-to-end/api/calendar.ts @@ -4,11 +4,9 @@ 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 { password } from '../../data/user'; import { createUser, deleteUser, login } from '../../data/users.helper'; -import { IS_EE } from '../../e2e/config/constants'; describe('[Calendar Events]', () => { let user2: IUser; @@ -665,41 +663,4 @@ describe('[Calendar Events]', () => { .expect(400); }); }); - - (IS_EE ? describe : describe.skip)('[Calendar Events Status Sync]', () => { - before(async () => { - await request.post('/api/v1/users.setStatus').set(userCredentials).send({ status: 'away' }).expect(200); - }); - - it('should set user status to busy during event and restore manual status after event ends', async () => { - const now = new Date(); - const startTime = new Date(now.getTime() + 1000); - // Event cannot be less than 5 secs in duration, otherwise `processStatusChangesAtTime` would trigger both start/end status changes at the same time, due to the 5s offset - const endTime = new Date(startTime.getTime() + 6000); - - const eventPayload = { - startTime: startTime.toISOString(), - endTime: endTime.toISOString(), - subject: 'Subject', - description: 'Description', - reminderMinutesBeforeStart: 1, - busy: true, - }; - - const createResponse = await request.post('/api/v1/calendar-events.create').set(userCredentials).send(eventPayload).expect(200); - const eventId = createResponse.body.id; - - await sleep(3000); - - const statusResponseDuring = await request.get('/api/v1/users.getStatus').set(userCredentials).expect(200); - expect(statusResponseDuring.body.status).to.equal('busy'); - - await sleep(5000); - - const statusResponseAfter = await request.get('/api/v1/users.getStatus').set(userCredentials).expect(200); - expect(statusResponseAfter.body.status).to.equal('away'); - - await request.post('/api/v1/calendar-events.delete').set(userCredentials).send({ eventId }).expect(200); - }); - }); }); From 359d88f0f2fa54a4b926acd107428dcd7cb56a8f Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 27 Mar 2026 08:03:26 -0600 Subject: [PATCH 24/41] right type --- ee/packages/abac/src/pdp/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ee/packages/abac/src/pdp/types.ts b/ee/packages/abac/src/pdp/types.ts index 413c51d05cc33..859805fd36776 100644 --- a/ee/packages/abac/src/pdp/types.ts +++ b/ee/packages/abac/src/pdp/types.ts @@ -27,7 +27,7 @@ export interface IGetDecisionBulkRequest { }>; } -export type Decision = 'DECISION_PERMIT' | 'DECISION_DENY' | 'DECISION_UNKNOWN'; +export type Decision = 'DECISION_PERMIT' | 'DECISION_DENY' | 'DECISION_UNSPECIFIED'; export interface IResourceDecision { decision?: Decision; From 73193dcb13c8130a6522cc622f0ec068eb2e82b4 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 27 Mar 2026 10:39:53 -0600 Subject: [PATCH 25/41] thanks calendar --- .changeset/red-windows-breathe.md | 5 ++ .../server/services/calendar/service.ts | 16 ++--- .../statusEvents/applyStatusChange.ts | 58 ++++++++++++------- 3 files changed, 51 insertions(+), 28 deletions(-) create mode 100644 .changeset/red-windows-breathe.md diff --git a/.changeset/red-windows-breathe.md b/.changeset/red-windows-breathe.md new file mode 100644 index 0000000000000..a177574edea6b --- /dev/null +++ b/.changeset/red-windows-breathe.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes calendar events modifying the wrong status property when attempting to sync `busy` status. diff --git a/apps/meteor/server/services/calendar/service.ts b/apps/meteor/server/services/calendar/service.ts index d41f80a55de3e..58900e9187462 100644 --- a/apps/meteor/server/services/calendar/service.ts +++ b/apps/meteor/server/services/calendar/service.ts @@ -177,7 +177,7 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe private async getMeetingUrl(eventData: Partial): Promise { if (eventData.meetingUrl !== undefined) { - return eventData.meetingUrl || undefined; + return eventData.meetingUrl; } if (eventData.description !== undefined) { @@ -285,15 +285,15 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe return; } - const user = await Users.findOneById(event.uid, { projection: { status: 1 } }); - if (!user || user.status === UserStatus.OFFLINE) { + const user = await Users.findOneById(event.uid, { projection: { statusDefault: 1 } }); + if (!user || user.statusDefault === UserStatus.OFFLINE) { return; } const overlappingEvents = await CalendarEvent.findOverlappingEvents(event._id, event.uid, event.startTime, event.endTime) .sort({ startTime: -1 }) .toArray(); - const previousStatus = overlappingEvents.at(0)?.previousStatus ?? user.status; + const previousStatus = overlappingEvents.at(0)?.previousStatus ?? user.statusDefault; if (previousStatus) { await CalendarEvent.updateEvent(event._id, { previousStatus }); @@ -313,16 +313,16 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe return; } - const user = await Users.findOneById(event.uid, { projection: { status: 1 } }); + const user = await Users.findOneById(event.uid, { projection: { statusDefault: 1 } }); if (!user) { return; } // Only restore status if: - // 1. The current status is BUSY (meaning it was set by our system, not manually changed by user) + // 1. The current statusDefault is BUSY (meaning it was set by our system, not manually changed by user) // 2. We have a previousStatus stored from before the event started - if (user.status === UserStatus.BUSY && event.previousStatus && event.previousStatus !== user.status) { + if (user.statusDefault === UserStatus.BUSY && event.previousStatus && event.previousStatus !== user.statusDefault) { await applyStatusChange({ eventId: event._id, uid: event.uid, @@ -334,7 +334,7 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe logger.debug({ msg: 'Not restoring status for user', userId: event.uid, - currentStatus: user.status, + currentStatusDefault: user.statusDefault, previousStatus: event.previousStatus, }); } diff --git a/apps/meteor/server/services/calendar/statusEvents/applyStatusChange.ts b/apps/meteor/server/services/calendar/statusEvents/applyStatusChange.ts index 251af36abadd6..83ef6d78a0f6b 100644 --- a/apps/meteor/server/services/calendar/statusEvents/applyStatusChange.ts +++ b/apps/meteor/server/services/calendar/statusEvents/applyStatusChange.ts @@ -20,33 +20,51 @@ export async function applyStatusChange({ status?: UserStatus; shouldScheduleRemoval?: boolean; }): Promise { + const user = await Users.findOneById(uid, { projection: { roles: 1, username: 1, name: 1, statusDefault: 1 } }); + + if (!user || user.statusDefault === UserStatus.OFFLINE) { + logger.debug({ + msg: 'Cannot apply status change for event, user is offline or does not exist', + eventId, + uid, + }); + + return; + } + + const newStatus = status ?? UserStatus.BUSY; + const previousStatus = user.statusDefault; + logger.debug({ msg: 'Applying status change for event', eventId, - uid, + user: { _id: uid, statusDefault: user?.statusDefault }, startTime, endTime, - status: status ?? UserStatus.BUSY, + newStatus, + previousStatus, }); - const user = await Users.findOneById(uid, { projection: { roles: 1, username: 1, name: 1, status: 1 } }); - if (!user || user.status === UserStatus.OFFLINE) { - return; + let statusChanged = false; + + if (newStatus === UserStatus.BUSY) { + await Users.updateStatusAndStatusDefault(uid, newStatus, newStatus); + statusChanged = true; + } else if (user.statusDefault === UserStatus.BUSY) { + await Users.updateStatusAndStatusDefault(uid, newStatus, newStatus); + statusChanged = true; } - const newStatus = status ?? UserStatus.BUSY; - const previousStatus = user.status; - - await Users.updateStatusAndStatusDefault(uid, newStatus, newStatus); - - await api.broadcast('presence.status', { - user: { - status: newStatus, - _id: uid, - roles: user.roles, - username: user.username, - name: user.name, - }, - previousStatus, - }); + if (statusChanged) { + await api.broadcast('presence.status', { + user: { + status: newStatus, + _id: uid, + roles: user.roles, + username: user.username, + name: user.name, + }, + previousStatus, + }); + } } From 534e8c81f1f896ab0a8c7812ce5ac7f76c1eedb0 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 27 Mar 2026 11:40:23 -0600 Subject: [PATCH 26/41] ouch --- .../server/services/calendar/service.ts | 2 +- apps/meteor/tests/end-to-end/api/calendar.ts | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/apps/meteor/server/services/calendar/service.ts b/apps/meteor/server/services/calendar/service.ts index 58900e9187462..853ecab6c95a2 100644 --- a/apps/meteor/server/services/calendar/service.ts +++ b/apps/meteor/server/services/calendar/service.ts @@ -177,7 +177,7 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe private async getMeetingUrl(eventData: Partial): Promise { if (eventData.meetingUrl !== undefined) { - return eventData.meetingUrl; + return eventData.meetingUrl || undefined; } if (eventData.description !== undefined) { diff --git a/apps/meteor/tests/end-to-end/api/calendar.ts b/apps/meteor/tests/end-to-end/api/calendar.ts index 3ce9cb4159cb5..b42093ec34b87 100644 --- a/apps/meteor/tests/end-to-end/api/calendar.ts +++ b/apps/meteor/tests/end-to-end/api/calendar.ts @@ -663,4 +663,41 @@ describe('[Calendar Events]', () => { .expect(400); }); }); + + (IS_EE ? describe : describe.skip)('[Calendar Events Status Sync]', () => { + before(async () => { + await request.post('/api/v1/users.setStatus').set(userCredentials).send({ status: 'away' }).expect(200); + }); + + it('should set user status to busy during event and restore manual status after event ends', async () => { + const now = new Date(); + const startTime = new Date(now.getTime() + 1000); + // Event cannot be less than 5 secs in duration, otherwise `processStatusChangesAtTime` would trigger both start/end status changes at the same time, due to the 5s offset + const endTime = new Date(startTime.getTime() + 6000); + + const eventPayload = { + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + subject: 'Subject', + description: 'Description', + reminderMinutesBeforeStart: 1, + busy: true, + }; + + const createResponse = await request.post('/api/v1/calendar-events.create').set(userCredentials).send(eventPayload).expect(200); + const eventId = createResponse.body.id; + + await sleep(3000); + + const statusResponseDuring = await request.get('/api/v1/users.getStatus').set(userCredentials).expect(200); + expect(statusResponseDuring.body.status).to.equal('busy'); + + await sleep(5000); + + const statusResponseAfter = await request.get('/api/v1/users.getStatus').set(userCredentials).expect(200); + expect(statusResponseAfter.body.status).to.equal('away'); + + await request.post('/api/v1/calendar-events.delete').set(userCredentials).send({ eventId }).expect(200); + }); + }); }); From aa9222ee6a8bdd520af843a057791f592200e537 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 27 Mar 2026 11:42:10 -0600 Subject: [PATCH 27/41] shame --- apps/meteor/tests/end-to-end/api/calendar.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/meteor/tests/end-to-end/api/calendar.ts b/apps/meteor/tests/end-to-end/api/calendar.ts index b42093ec34b87..930a6e8b80483 100644 --- a/apps/meteor/tests/end-to-end/api/calendar.ts +++ b/apps/meteor/tests/end-to-end/api/calendar.ts @@ -5,8 +5,10 @@ import { after, before, describe, it } from 'mocha'; import type { Response } from 'supertest'; 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'; describe('[Calendar Events]', () => { let user2: IUser; From ab77362130bfd5c25850e1d69dd33d26cbb4abb0 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 30 Mar 2026 10:13:14 -0600 Subject: [PATCH 28/41] test: External PDP (#39913) --- apps/meteor/tests/data/mock-server.helper.ts | 87 ++++ apps/meteor/tests/e2e/config/constants.ts | 2 + apps/meteor/tests/end-to-end/api/abac.ts | 432 ++++++++++++++++++- development/mockServer/Dockerfile | 13 + development/mockServer/go.mod | 3 + development/mockServer/main.go | 361 ++++++++++++++++ docker-compose-ci.yml | 12 + 7 files changed, 909 insertions(+), 1 deletion(-) create mode 100644 apps/meteor/tests/data/mock-server.helper.ts create mode 100644 development/mockServer/Dockerfile create mode 100644 development/mockServer/go.mod create mode 100644 development/mockServer/main.go 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..c68717fb8bc67 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,424 @@ 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); + }); + }); + + 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/development/mockServer/Dockerfile b/development/mockServer/Dockerfile new file mode 100644 index 0000000000000..1482eca071ec1 --- /dev/null +++ b/development/mockServer/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.23-alpine AS build +WORKDIR /src +COPY go.mod ./ +COPY main.go ./ +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /mock-server . +RUN adduser -D -u 1001 mockuser + +FROM scratch +COPY --from=build /etc/passwd /etc/passwd +COPY --from=build /mock-server /mock-server +USER mockuser +EXPOSE 8080 +ENTRYPOINT ["/mock-server"] diff --git a/development/mockServer/go.mod b/development/mockServer/go.mod new file mode 100644 index 0000000000000..debf7bad4d70d --- /dev/null +++ b/development/mockServer/go.mod @@ -0,0 +1,3 @@ +module github.com/opentdf/mock-server + +go 1.23 diff --git a/development/mockServer/main.go b/development/mockServer/main.go new file mode 100644 index 0000000000000..76ea57c75968a --- /dev/null +++ b/development/mockServer/main.go @@ -0,0 +1,361 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "strings" + "sync" + "time" +) + +type MockResponse struct { + StatusCode int `json:"status_code"` + Headers map[string]string `json:"headers,omitempty"` + Body json.RawMessage `json:"body"` + Times int `json:"times,omitempty"` +} + +type MockRule struct { + MockResponse + remaining int // -1 = unlimited +} + +type DynamicBulkRule struct { + PermitValues []string `json:"permit_values"` + DefaultDecision string `json:"default_decision"` +} + +type server struct { + mu sync.Mutex + mocks map[string][]*MockRule + dynamicBulk *DynamicBulkRule + log []RequestLog +} + +type RequestLog struct { + Timestamp string `json:"timestamp"` + Method string `json:"method"` + Path string `json:"path"` + Headers map[string]string `json:"headers"` + Body json.RawMessage `json:"body,omitempty"` + Matched bool `json:"matched"` +} + +func (s *server) handleSet(w http.ResponseWriter, r *http.Request) { + var req struct { + Method string `json:"method"` + Path string `json:"path"` + Response MockResponse `json:"response"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusBadRequest) + return + } + if req.Method == "" { + req.Method = "POST" + } + if req.Response.StatusCode == 0 { + req.Response.StatusCode = 200 + } + + remaining := req.Response.Times + if remaining == 0 { + remaining = -1 + } + + key := req.Method + " " + req.Path + rule := &MockRule{MockResponse: req.Response, remaining: remaining} + + s.mu.Lock() + s.mocks[key] = append(s.mocks[key], rule) + s.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"ok":true,"key":%q}`, key) +} + +func (s *server) handleSetMany(w http.ResponseWriter, r *http.Request) { + var reqs []struct { + Method string `json:"method"` + Path string `json:"path"` + Response MockResponse `json:"response"` + } + if err := json.NewDecoder(r.Body).Decode(&reqs); err != nil { + http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusBadRequest) + return + } + + s.mu.Lock() + for _, req := range reqs { + if req.Method == "" { + req.Method = "POST" + } + if req.Response.StatusCode == 0 { + req.Response.StatusCode = 200 + } + remaining := req.Response.Times + if remaining == 0 { + remaining = -1 + } + key := req.Method + " " + req.Path + s.mocks[key] = append(s.mocks[key], &MockRule{MockResponse: req.Response, remaining: remaining}) + } + s.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"ok":true,"count":%d}`, len(reqs)) +} + +// POST /__mock/set-bulk-decision — register a dynamic bulk decision handler. +// +// { +// "permit_values": ["admin@test.com"], +// "default_decision": "DECISION_DENY" +// } +// +// When a GetDecisionBulk request comes in, the mock inspects each entity's +// emailAddress or id field. If the value is in permit_values → DECISION_PERMIT, +// otherwise → default_decision. +func (s *server) handleSetBulkDecision(w http.ResponseWriter, r *http.Request) { + var rule DynamicBulkRule + if err := json.NewDecoder(r.Body).Decode(&rule); err != nil { + http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusBadRequest) + return + } + if rule.DefaultDecision == "" { + rule.DefaultDecision = "DECISION_DENY" + } + + s.mu.Lock() + s.dynamicBulk = &rule + s.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"ok":true}`) +} + +func (s *server) handleReset(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + s.mocks = make(map[string][]*MockRule) + s.dynamicBulk = nil + s.log = nil + s.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"ok":true}`) +} + +func (s *server) handleLog(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + logs := s.log + s.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(logs) +} + +func (s *server) handleHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"status":"ok"}`) +} + +// buildDynamicBulkResponse inspects a GetDecisionBulk request body and returns +// per-entity decisions based on the dynamic rule. +func (s *server) buildDynamicBulkResponse(rule *DynamicBulkRule, body json.RawMessage) json.RawMessage { + var req struct { + DecisionRequests []struct { + EntityIdentifier struct { + EntityChain struct { + Entities []json.RawMessage `json:"entities"` + } `json:"entityChain"` + } `json:"entityIdentifier"` + Resources []struct { + EphemeralId string `json:"ephemeralId"` + } `json:"resources"` + } `json:"decisionRequests"` + } + if err := json.Unmarshal(body, &req); err != nil { + log.Printf("dynamic bulk: failed to parse request: %v", err) + return []byte(`{"decisionResponses":[]}`) + } + + permitSet := make(map[string]bool, len(rule.PermitValues)) + for _, v := range rule.PermitValues { + permitSet[strings.ToLower(v)] = true + } + + type resourceDecision struct { + Decision string `json:"decision"` + EphemeralResourceId string `json:"ephemeralResourceId,omitempty"` + } + type decisionResponse struct { + ResourceDecisions []resourceDecision `json:"resourceDecisions"` + } + + var responses []decisionResponse + for _, dr := range req.DecisionRequests { + entityValue := extractEntityValue(dr.EntityIdentifier.EntityChain.Entities) + decision := rule.DefaultDecision + if permitSet[strings.ToLower(entityValue)] { + decision = "DECISION_PERMIT" + } + + var rds []resourceDecision + for _, res := range dr.Resources { + rds = append(rds, resourceDecision{ + Decision: decision, + EphemeralResourceId: res.EphemeralId, + }) + } + if len(rds) == 0 { + rds = append(rds, resourceDecision{Decision: decision}) + } + responses = append(responses, decisionResponse{ResourceDecisions: rds}) + } + + result, _ := json.Marshal(map[string]interface{}{ + "decisionResponses": responses, + }) + return result +} + +// extractEntityValue pulls the emailAddress or id from an entity object. +func extractEntityValue(entities []json.RawMessage) string { + for _, raw := range entities { + var entity map[string]interface{} + if err := json.Unmarshal(raw, &entity); err != nil { + continue + } + if v, ok := entity["emailAddress"].(string); ok { + return v + } + if v, ok := entity["id"].(string); ok { + return v + } + } + return "" +} + +func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/__mock/set" && r.Method == http.MethodPost: + s.handleSet(w, r) + return + case r.URL.Path == "/__mock/set-many" && r.Method == http.MethodPost: + s.handleSetMany(w, r) + return + case r.URL.Path == "/__mock/set-bulk-decision" && r.Method == http.MethodPost: + s.handleSetBulkDecision(w, r) + return + case r.URL.Path == "/__mock/reset" && (r.Method == http.MethodDelete || r.Method == http.MethodPost): + s.handleReset(w, r) + return + case r.URL.Path == "/__mock/log" && r.Method == http.MethodGet: + s.handleLog(w, r) + return + case r.URL.Path == "/__mock/health" && r.Method == http.MethodGet: + s.handleHealth(w, r) + return + } + + var bodyBytes json.RawMessage + if r.Body != nil { + _ = json.NewDecoder(r.Body).Decode(&bodyBytes) + } + + headers := make(map[string]string) + for k := range r.Header { + headers[k] = r.Header.Get(k) + } + + entry := RequestLog{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + Method: r.Method, + Path: r.URL.Path, + Headers: headers, + Body: bodyBytes, + } + + key := r.Method + " " + r.URL.Path + + s.mu.Lock() + + // Check for dynamic bulk decision handler first (only for GetDecisionBulk). + if strings.HasSuffix(r.URL.Path, "/GetDecisionBulk") && s.dynamicBulk != nil { + rule := s.dynamicBulk + entry.Matched = true + s.log = append(s.log, entry) + s.mu.Unlock() + + responseBody := s.buildDynamicBulkResponse(rule, bodyBytes) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(responseBody) + log.Printf("HIT %s %s → 200 (dynamic bulk)", r.Method, r.URL.Path) + return + } + + rules := s.mocks[key] + var matched *MockRule + if len(rules) > 0 { + matched = rules[0] + if matched.remaining > 0 { + matched.remaining-- + if matched.remaining == 0 { + s.mocks[key] = rules[1:] + } + } + } + entry.Matched = matched != nil + s.log = append(s.log, entry) + s.mu.Unlock() + + if matched == nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, `{"error":"no mock registered","method":%q,"path":%q}`, r.Method, r.URL.Path) + log.Printf("MISS %s %s", r.Method, r.URL.Path) + return + } + + for k, v := range matched.Headers { + w.Header().Set(k, v) + } + if w.Header().Get("Content-Type") == "" { + w.Header().Set("Content-Type", "application/json") + } + w.WriteHeader(matched.StatusCode) + w.Write(matched.Body) + log.Printf("HIT %s %s → %d", r.Method, r.URL.Path, matched.StatusCode) +} + +func main() { + if len(os.Args) > 1 && os.Args[1] == "-healthcheck" { + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + resp, err := http.Get("http://127.0.0.1:" + port + "/__mock/health") + if err != nil || resp.StatusCode != 200 { + os.Exit(1) + } + os.Exit(0) + } + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + s := &server{ + mocks: make(map[string][]*MockRule), + } + + log.Printf("mock-server listening on :%s", port) + if err := http.ListenAndServe(":"+port, s); err != nil { + log.Fatal(err) + } +} diff --git a/docker-compose-ci.yml b/docker-compose-ci.yml index e5f71d5937c54..80dee56453df3 100644 --- a/docker-compose-ci.yml +++ b/docker-compose-ci.yml @@ -200,6 +200,18 @@ services: httpbin: image: kong/httpbin + mock-server: + build: + context: ./development/mockServer + dockerfile: Dockerfile + ports: + - 4000:8080 + healthcheck: + test: ["CMD", "/mock-server", "-healthcheck"] + interval: 2s + timeout: 5s + retries: 5 + traefik: image: traefik:v3.6.6 command: From 819d1e735de16cd11fc439abe6ce51ae5792d675 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 30 Mar 2026 11:33:15 -0600 Subject: [PATCH 29/41] fix: Prevent attr update when external pdp is down (#39978) --- apps/meteor/tests/end-to-end/api/abac.ts | 15 +++++++++++++++ ee/packages/abac/src/errors.ts | 7 +++++++ ee/packages/abac/src/index.ts | 11 +++++++++++ ee/packages/abac/src/pdp/VirtruPDP.ts | 8 -------- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/abac.ts b/apps/meteor/tests/end-to-end/api/abac.ts index c68717fb8bc67..dfba6cc11867e 100644 --- a/apps/meteor/tests/end-to-end/api/abac.ts +++ b/apps/meteor/tests/end-to-end/api/abac.ts @@ -2942,6 +2942,21 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I 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', () => { diff --git a/ee/packages/abac/src/errors.ts b/ee/packages/abac/src/errors.ts index 0b7072c39b617..6724fcae13fb5 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,9 @@ export class OnlyCompliantCanBeAddedToRoomError extends AbacError { super(AbacErrorCode.OnlyCompliantCanBeAddedToRoom, details); } } + +export class PdpUnavailableError extends AbacError { + constructor(details?: unknown) { + super(AbacErrorCode.PdpUnavailable, details); + } +} diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 8f5847b7b8c1d..4c3b794574941 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -24,6 +24,7 @@ import { AbacInvalidAttributeValuesError, AbacUnsupportedObjectTypeError, AbacUnsupportedOperationError, + PdpUnavailableError, } from './errors'; import { getAbacRoom, @@ -416,6 +417,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) { @@ -438,6 +440,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 || []; @@ -514,6 +517,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); @@ -536,6 +540,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); @@ -649,6 +654,12 @@ 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, diff --git a/ee/packages/abac/src/pdp/VirtruPDP.ts b/ee/packages/abac/src/pdp/VirtruPDP.ts index 6031c4acbfb68..f5935df872479 100644 --- a/ee/packages/abac/src/pdp/VirtruPDP.ts +++ b/ee/packages/abac/src/pdp/VirtruPDP.ts @@ -297,14 +297,6 @@ export class VirtruPDP implements IPolicyDecisionPoint { return []; } - if (!(await this.isAvailable())) { - pdpLogger.warn({ - msg: 'Virtru PDP is unavailable, skipping room attributes evaluation — no users will be removed', - roomId: room._id, - }); - return []; - } - const users = Users.findActiveByRoomIds([room._id], { projection: { _id: 1, emails: 1, username: 1 }, }); From ebb075b65e9541f924bd49395442007e2c3ec1ef Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 1 Apr 2026 13:37:49 -0600 Subject: [PATCH 30/41] chore: Detailed connection checks for Virtru PDP (#40013) --- apps/meteor/ee/server/api/abac/index.ts | 14 ++++- apps/meteor/ee/server/api/abac/schemas.ts | 19 +++++- apps/meteor/ee/server/settings/abac.ts | 11 ++++ ee/packages/abac/src/index.ts | 6 +- ee/packages/abac/src/pdp/LocalPDP.ts | 4 ++ ee/packages/abac/src/pdp/VirtruPDP.ts | 58 +++++++++++++++++++ ee/packages/abac/src/pdp/types.ts | 2 + .../core-services/src/types/IAbacService.ts | 2 +- packages/core-typings/src/ISetting.ts | 1 + packages/i18n/src/locales/en.i18n.json | 7 +++ 10 files changed, 116 insertions(+), 8 deletions(-) diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index 68c46890a2a43..b7fa699ca8c12 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -23,6 +23,7 @@ import { GETAbacAuditEventsQuerySchema, GETAbacAuditEventsResponseSchema, GETAbacPdpHealthResponseSchema, + GETAbacPdpHealthErrorResponseSchema, } from './schemas'; import { API } from '../../../../app/api/server'; import type { ExtractRoutesFromAPI } from '../../../../app/api/server/ApiClass'; @@ -363,15 +364,24 @@ const abacEndpoints = API.v1 { authRequired: true, permissionsRequired: ['abac-management'], + rateLimiterOptions: { + numRequestsAllowed: 5, + intervalTimeInMS: 60000, + }, response: { 200: GETAbacPdpHealthResponseSchema, + 400: GETAbacPdpHealthErrorResponseSchema, 401: validateUnauthorizedErrorResponse, 403: validateUnauthorizedErrorResponse, }, }, async function action() { - const available = await Abac.isPdpAvailable(); - return API.v1.success({ available }); + try { + await Abac.getPDPHealth(); + return API.v1.success({ available: true, message: 'ABAC_PDP_Health_OK' }); + } catch (err) { + return API.v1.failure({ available: false, message: (err as Error).message }); + } }, ) .get( diff --git a/apps/meteor/ee/server/api/abac/schemas.ts b/apps/meteor/ee/server/api/abac/schemas.ts index 34cdca8992ca5..6c8f5a257d615 100644 --- a/apps/meteor/ee/server/api/abac/schemas.ts +++ b/apps/meteor/ee/server/api/abac/schemas.ts @@ -342,13 +342,28 @@ export const POSTAbacUsersSyncBodySchema = ajv.compile<{ export const GenericErrorSchema = ajv.compile<{ success: boolean; message: string }>(GenericError); -export const GETAbacPdpHealthResponseSchema = ajv.compile<{ available: boolean }>({ +export const GETAbacPdpHealthResponseSchema = ajv.compile<{ + available: boolean; + message: string; +}>({ type: 'object', properties: { success: { type: 'boolean', enum: [true] }, available: { type: 'boolean' }, + message: { type: 'string' }, }, - required: ['available'], + required: ['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: ['available', 'message'], additionalProperties: false, }); diff --git a/apps/meteor/ee/server/settings/abac.ts b/apps/meteor/ee/server/settings/abac.ts index 9eaea35fc48f7..3641db57e004c 100644 --- a/apps/meteor/ee/server/settings/abac.ts +++ b/apps/meteor/ee/server/settings/abac.ts @@ -102,6 +102,17 @@ export function addSettings(): Promise { 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/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 4c3b794574941..1bbd273ebacc0 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -742,12 +742,12 @@ export class AbacService extends ServiceClass implements IAbacService { } } - async isPdpAvailable(): Promise { + async getPDPHealth(): Promise { if (!this.pdp) { - return false; + throw new Error('ABAC_PDP_Health_No_PDP'); } - return this.pdp.isAvailable(); + await this.pdp.getHealthStatus(); } async evaluateRoomMembership(): Promise { diff --git a/ee/packages/abac/src/pdp/LocalPDP.ts b/ee/packages/abac/src/pdp/LocalPDP.ts index da62356f4bab0..2b1d3ed51174e 100644 --- a/ee/packages/abac/src/pdp/LocalPDP.ts +++ b/ee/packages/abac/src/pdp/LocalPDP.ts @@ -10,6 +10,10 @@ export class LocalPDP implements IPolicyDecisionPoint { return true; } + async getHealthStatus(): Promise { + // Local PDP is always available, nothing to check + } + async canAccessObject( room: AtLeast, user: AtLeast, diff --git a/ee/packages/abac/src/pdp/VirtruPDP.ts b/ee/packages/abac/src/pdp/VirtruPDP.ts index f5935df872479..4972b3be48919 100644 --- a/ee/packages/abac/src/pdp/VirtruPDP.ts +++ b/ee/packages/abac/src/pdp/VirtruPDP.ts @@ -21,6 +21,8 @@ import type { const pdpLogger = logger.section('VirtruPDP'); +const HEALTH_CHECK_TIMEOUT = 10000; + export class VirtruPDP implements IPolicyDecisionPoint { private tokenCache: ITokenCache | null = null; @@ -57,6 +59,62 @@ export class VirtruPDP implements IPolicyDecisionPoint { } } + 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 Error('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 Error('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 Error('ABAC_PDP_Health_Authorization_Failed'); + } + } + private async getClientToken(): Promise { if (this.tokenCache && Date.now() < this.tokenCache.expiresAt) { return this.tokenCache.accessToken; diff --git a/ee/packages/abac/src/pdp/types.ts b/ee/packages/abac/src/pdp/types.ts index 859805fd36776..22a8d4b855c4e 100644 --- a/ee/packages/abac/src/pdp/types.ts +++ b/ee/packages/abac/src/pdp/types.ts @@ -49,6 +49,8 @@ export interface IGetDecisionBulkResponse { export interface IPolicyDecisionPoint { isAvailable(): Promise; + getHealthStatus(): Promise; + canAccessObject( room: AtLeast, user: AtLeast, diff --git a/packages/core-services/src/types/IAbacService.ts b/packages/core-services/src/types/IAbacService.ts index ab111a32ae49f..17e9a19fe93bf 100644 --- a/packages/core-services/src/types/IAbacService.ts +++ b/packages/core-services/src/types/IAbacService.ts @@ -47,5 +47,5 @@ export interface IAbacService { ): Promise; addSubjectAttributes(user: IUser, ldapUser: ILDAPEntry, map: Record, actor: AbacActor | undefined): Promise; evaluateRoomMembership(): Promise; - isPdpAvailable(): 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/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 0761f6e91bfa8..21c072a627261 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -12,6 +12,13 @@ "@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_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)", From 858eec855fb23eb1b33b3ab73ac466e7f4ffb035 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 6 Apr 2026 19:15:17 -0600 Subject: [PATCH 31/41] test: ExternalPDP (2) (#40055) --- .github/workflows/ci-test-e2e.yml | 1 + development/mockServer/Dockerfile | 13 -- development/mockServer/go.mod | 3 - development/mockServer/main.go | 361 ------------------------------ docker-compose-ci.yml | 6 +- 5 files changed, 4 insertions(+), 380 deletions(-) delete mode 100644 development/mockServer/Dockerfile delete mode 100644 development/mockServer/go.mod delete mode 100644 development/mockServer/main.go 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/development/mockServer/Dockerfile b/development/mockServer/Dockerfile deleted file mode 100644 index 1482eca071ec1..0000000000000 --- a/development/mockServer/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM golang:1.23-alpine AS build -WORKDIR /src -COPY go.mod ./ -COPY main.go ./ -RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /mock-server . -RUN adduser -D -u 1001 mockuser - -FROM scratch -COPY --from=build /etc/passwd /etc/passwd -COPY --from=build /mock-server /mock-server -USER mockuser -EXPOSE 8080 -ENTRYPOINT ["/mock-server"] diff --git a/development/mockServer/go.mod b/development/mockServer/go.mod deleted file mode 100644 index debf7bad4d70d..0000000000000 --- a/development/mockServer/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module github.com/opentdf/mock-server - -go 1.23 diff --git a/development/mockServer/main.go b/development/mockServer/main.go deleted file mode 100644 index 76ea57c75968a..0000000000000 --- a/development/mockServer/main.go +++ /dev/null @@ -1,361 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "log" - "net/http" - "os" - "strings" - "sync" - "time" -) - -type MockResponse struct { - StatusCode int `json:"status_code"` - Headers map[string]string `json:"headers,omitempty"` - Body json.RawMessage `json:"body"` - Times int `json:"times,omitempty"` -} - -type MockRule struct { - MockResponse - remaining int // -1 = unlimited -} - -type DynamicBulkRule struct { - PermitValues []string `json:"permit_values"` - DefaultDecision string `json:"default_decision"` -} - -type server struct { - mu sync.Mutex - mocks map[string][]*MockRule - dynamicBulk *DynamicBulkRule - log []RequestLog -} - -type RequestLog struct { - Timestamp string `json:"timestamp"` - Method string `json:"method"` - Path string `json:"path"` - Headers map[string]string `json:"headers"` - Body json.RawMessage `json:"body,omitempty"` - Matched bool `json:"matched"` -} - -func (s *server) handleSet(w http.ResponseWriter, r *http.Request) { - var req struct { - Method string `json:"method"` - Path string `json:"path"` - Response MockResponse `json:"response"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusBadRequest) - return - } - if req.Method == "" { - req.Method = "POST" - } - if req.Response.StatusCode == 0 { - req.Response.StatusCode = 200 - } - - remaining := req.Response.Times - if remaining == 0 { - remaining = -1 - } - - key := req.Method + " " + req.Path - rule := &MockRule{MockResponse: req.Response, remaining: remaining} - - s.mu.Lock() - s.mocks[key] = append(s.mocks[key], rule) - s.mu.Unlock() - - w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `{"ok":true,"key":%q}`, key) -} - -func (s *server) handleSetMany(w http.ResponseWriter, r *http.Request) { - var reqs []struct { - Method string `json:"method"` - Path string `json:"path"` - Response MockResponse `json:"response"` - } - if err := json.NewDecoder(r.Body).Decode(&reqs); err != nil { - http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusBadRequest) - return - } - - s.mu.Lock() - for _, req := range reqs { - if req.Method == "" { - req.Method = "POST" - } - if req.Response.StatusCode == 0 { - req.Response.StatusCode = 200 - } - remaining := req.Response.Times - if remaining == 0 { - remaining = -1 - } - key := req.Method + " " + req.Path - s.mocks[key] = append(s.mocks[key], &MockRule{MockResponse: req.Response, remaining: remaining}) - } - s.mu.Unlock() - - w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `{"ok":true,"count":%d}`, len(reqs)) -} - -// POST /__mock/set-bulk-decision — register a dynamic bulk decision handler. -// -// { -// "permit_values": ["admin@test.com"], -// "default_decision": "DECISION_DENY" -// } -// -// When a GetDecisionBulk request comes in, the mock inspects each entity's -// emailAddress or id field. If the value is in permit_values → DECISION_PERMIT, -// otherwise → default_decision. -func (s *server) handleSetBulkDecision(w http.ResponseWriter, r *http.Request) { - var rule DynamicBulkRule - if err := json.NewDecoder(r.Body).Decode(&rule); err != nil { - http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusBadRequest) - return - } - if rule.DefaultDecision == "" { - rule.DefaultDecision = "DECISION_DENY" - } - - s.mu.Lock() - s.dynamicBulk = &rule - s.mu.Unlock() - - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"ok":true}`) -} - -func (s *server) handleReset(w http.ResponseWriter, r *http.Request) { - s.mu.Lock() - s.mocks = make(map[string][]*MockRule) - s.dynamicBulk = nil - s.log = nil - s.mu.Unlock() - - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"ok":true}`) -} - -func (s *server) handleLog(w http.ResponseWriter, r *http.Request) { - s.mu.Lock() - logs := s.log - s.mu.Unlock() - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(logs) -} - -func (s *server) handleHealth(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"status":"ok"}`) -} - -// buildDynamicBulkResponse inspects a GetDecisionBulk request body and returns -// per-entity decisions based on the dynamic rule. -func (s *server) buildDynamicBulkResponse(rule *DynamicBulkRule, body json.RawMessage) json.RawMessage { - var req struct { - DecisionRequests []struct { - EntityIdentifier struct { - EntityChain struct { - Entities []json.RawMessage `json:"entities"` - } `json:"entityChain"` - } `json:"entityIdentifier"` - Resources []struct { - EphemeralId string `json:"ephemeralId"` - } `json:"resources"` - } `json:"decisionRequests"` - } - if err := json.Unmarshal(body, &req); err != nil { - log.Printf("dynamic bulk: failed to parse request: %v", err) - return []byte(`{"decisionResponses":[]}`) - } - - permitSet := make(map[string]bool, len(rule.PermitValues)) - for _, v := range rule.PermitValues { - permitSet[strings.ToLower(v)] = true - } - - type resourceDecision struct { - Decision string `json:"decision"` - EphemeralResourceId string `json:"ephemeralResourceId,omitempty"` - } - type decisionResponse struct { - ResourceDecisions []resourceDecision `json:"resourceDecisions"` - } - - var responses []decisionResponse - for _, dr := range req.DecisionRequests { - entityValue := extractEntityValue(dr.EntityIdentifier.EntityChain.Entities) - decision := rule.DefaultDecision - if permitSet[strings.ToLower(entityValue)] { - decision = "DECISION_PERMIT" - } - - var rds []resourceDecision - for _, res := range dr.Resources { - rds = append(rds, resourceDecision{ - Decision: decision, - EphemeralResourceId: res.EphemeralId, - }) - } - if len(rds) == 0 { - rds = append(rds, resourceDecision{Decision: decision}) - } - responses = append(responses, decisionResponse{ResourceDecisions: rds}) - } - - result, _ := json.Marshal(map[string]interface{}{ - "decisionResponses": responses, - }) - return result -} - -// extractEntityValue pulls the emailAddress or id from an entity object. -func extractEntityValue(entities []json.RawMessage) string { - for _, raw := range entities { - var entity map[string]interface{} - if err := json.Unmarshal(raw, &entity); err != nil { - continue - } - if v, ok := entity["emailAddress"].(string); ok { - return v - } - if v, ok := entity["id"].(string); ok { - return v - } - } - return "" -} - -func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - switch { - case r.URL.Path == "/__mock/set" && r.Method == http.MethodPost: - s.handleSet(w, r) - return - case r.URL.Path == "/__mock/set-many" && r.Method == http.MethodPost: - s.handleSetMany(w, r) - return - case r.URL.Path == "/__mock/set-bulk-decision" && r.Method == http.MethodPost: - s.handleSetBulkDecision(w, r) - return - case r.URL.Path == "/__mock/reset" && (r.Method == http.MethodDelete || r.Method == http.MethodPost): - s.handleReset(w, r) - return - case r.URL.Path == "/__mock/log" && r.Method == http.MethodGet: - s.handleLog(w, r) - return - case r.URL.Path == "/__mock/health" && r.Method == http.MethodGet: - s.handleHealth(w, r) - return - } - - var bodyBytes json.RawMessage - if r.Body != nil { - _ = json.NewDecoder(r.Body).Decode(&bodyBytes) - } - - headers := make(map[string]string) - for k := range r.Header { - headers[k] = r.Header.Get(k) - } - - entry := RequestLog{ - Timestamp: time.Now().UTC().Format(time.RFC3339), - Method: r.Method, - Path: r.URL.Path, - Headers: headers, - Body: bodyBytes, - } - - key := r.Method + " " + r.URL.Path - - s.mu.Lock() - - // Check for dynamic bulk decision handler first (only for GetDecisionBulk). - if strings.HasSuffix(r.URL.Path, "/GetDecisionBulk") && s.dynamicBulk != nil { - rule := s.dynamicBulk - entry.Matched = true - s.log = append(s.log, entry) - s.mu.Unlock() - - responseBody := s.buildDynamicBulkResponse(rule, bodyBytes) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - w.Write(responseBody) - log.Printf("HIT %s %s → 200 (dynamic bulk)", r.Method, r.URL.Path) - return - } - - rules := s.mocks[key] - var matched *MockRule - if len(rules) > 0 { - matched = rules[0] - if matched.remaining > 0 { - matched.remaining-- - if matched.remaining == 0 { - s.mocks[key] = rules[1:] - } - } - } - entry.Matched = matched != nil - s.log = append(s.log, entry) - s.mu.Unlock() - - if matched == nil { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusNotFound) - fmt.Fprintf(w, `{"error":"no mock registered","method":%q,"path":%q}`, r.Method, r.URL.Path) - log.Printf("MISS %s %s", r.Method, r.URL.Path) - return - } - - for k, v := range matched.Headers { - w.Header().Set(k, v) - } - if w.Header().Get("Content-Type") == "" { - w.Header().Set("Content-Type", "application/json") - } - w.WriteHeader(matched.StatusCode) - w.Write(matched.Body) - log.Printf("HIT %s %s → %d", r.Method, r.URL.Path, matched.StatusCode) -} - -func main() { - if len(os.Args) > 1 && os.Args[1] == "-healthcheck" { - port := os.Getenv("PORT") - if port == "" { - port = "8080" - } - resp, err := http.Get("http://127.0.0.1:" + port + "/__mock/health") - if err != nil || resp.StatusCode != 200 { - os.Exit(1) - } - os.Exit(0) - } - - port := os.Getenv("PORT") - if port == "" { - port = "8080" - } - - s := &server{ - mocks: make(map[string][]*MockRule), - } - - log.Printf("mock-server listening on :%s", port) - if err := http.ListenAndServe(":"+port, s); err != nil { - log.Fatal(err) - } -} diff --git a/docker-compose-ci.yml b/docker-compose-ci.yml index 80dee56453df3..32e31b448f9b4 100644 --- a/docker-compose-ci.yml +++ b/docker-compose-ci.yml @@ -201,9 +201,9 @@ services: image: kong/httpbin mock-server: - build: - context: ./development/mockServer - dockerfile: Dockerfile + image: kaleman14/mockserver:latest + profiles: + - api ports: - 4000:8080 healthcheck: From 7a2813acc30aa7be642748df1e13b64f7c0233bf Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 7 Apr 2026 09:32:40 -0600 Subject: [PATCH 32/41] Create tall-singers-roll.md --- .changeset/tall-singers-roll.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/tall-singers-roll.md 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. From 1d2aba3033fd2113eb9ea67c3428ca5881a51860 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 7 Apr 2026 11:58:58 -0600 Subject: [PATCH 33/41] fix: Review fixes (#40065) --- apps/meteor/ee/server/api/abac/schemas.ts | 4 ++-- apps/meteor/ee/server/configuration/abac.ts | 10 +++++++++- docker-compose-ci.yml | 1 + ee/packages/abac/src/index.ts | 10 ++++++---- ee/packages/abac/src/pdp/VirtruPDP.ts | 8 +++++++- 5 files changed, 25 insertions(+), 8 deletions(-) diff --git a/apps/meteor/ee/server/api/abac/schemas.ts b/apps/meteor/ee/server/api/abac/schemas.ts index 6c8f5a257d615..582b43291d1f3 100644 --- a/apps/meteor/ee/server/api/abac/schemas.ts +++ b/apps/meteor/ee/server/api/abac/schemas.ts @@ -352,7 +352,7 @@ export const GETAbacPdpHealthResponseSchema = ajv.compile<{ available: { type: 'boolean' }, message: { type: 'string' }, }, - required: ['available', 'message'], + required: ['success', 'available', 'message'], additionalProperties: false, }); @@ -363,7 +363,7 @@ export const GETAbacPdpHealthErrorResponseSchema = ajv.compile<{ available: bool available: { type: 'boolean', enum: [false] }, message: { type: 'string' }, }, - required: ['available', 'message'], + required: ['success', 'available', 'message'], additionalProperties: false, }); diff --git a/apps/meteor/ee/server/configuration/abac.ts b/apps/meteor/ee/server/configuration/abac.ts index 1dd4fad523216..576e29797265a 100644 --- a/apps/meteor/ee/server/configuration/abac.ts +++ b/apps/meteor/ee/server/configuration/abac.ts @@ -2,6 +2,7 @@ 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'; @@ -43,10 +44,17 @@ Meteor.startup(async () => { 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_PDP_Type', 'ABAC_Virtru_Sync_Interval'], () => configureVirtruPdpSync()); + stopCronWatcher = settings.watchMultiple( + ['ABAC_Enabled', 'ABAC_PDP_Type', 'ABAC_Virtru_Sync_Interval'], + () => void configureVirtruPdpSync(), + ); }, down: async () => { stopWatcher?.(); diff --git a/docker-compose-ci.yml b/docker-compose-ci.yml index 32e31b448f9b4..618975a3d2106 100644 --- a/docker-compose-ci.yml +++ b/docker-compose-ci.yml @@ -56,6 +56,7 @@ services: SERVICE: authorization-service image: ghcr.io/${LOWERCASE_REPOSITORY}/authorization-service:${DOCKER_TAG} environment: + - ABAC_HEALTH_CACHE_TTL_MS=1 - MONGO_URL=mongodb://mongo:27017/rocketchat?replicaSet=rs0 - 'TRANSPORTER=${TRANSPORTER:-}' - MOLECULER_LOG_LEVEL=info diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 1bbd273ebacc0..0756e0721a045 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -42,6 +42,8 @@ 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'; @@ -77,7 +79,7 @@ export class AbacService extends ServiceClass implements IAbacService { }); this.onSettingChanged('ABAC_Virtru_Base_URL', async ({ setting }): Promise => { - this.virtruPdpConfig.baseUrl = (setting.value as string).replace(/\/+$/, ''); + this.virtruPdpConfig.baseUrl = stripTrailingSlashes(setting.value as string); this.syncVirtruPdpConfig(); }); @@ -92,7 +94,7 @@ export class AbacService extends ServiceClass implements IAbacService { }); this.onSettingChanged('ABAC_Virtru_OIDC_Endpoint', async ({ setting }): Promise => { - this.virtruPdpConfig.oidcEndpoint = (setting.value as string).replace(/\/+$/, ''); + this.virtruPdpConfig.oidcEndpoint = stripTrailingSlashes(setting.value as string); this.syncVirtruPdpConfig(); }); @@ -155,10 +157,10 @@ export class AbacService extends ServiceClass implements IAbacService { ]); this.virtruPdpConfig = { - baseUrl: (baseUrl || '').replace(/\/+$/, ''), + baseUrl: stripTrailingSlashes(baseUrl || ''), clientId: clientId || '', clientSecret: clientSecret || '', - oidcEndpoint: (oidcEndpoint || '').replace(/\/+$/, ''), + oidcEndpoint: stripTrailingSlashes(oidcEndpoint || ''), defaultEntityKey: (defaultEntityKey as VirtruPDPConfig['defaultEntityKey']) || 'emailAddress', attributeNamespace: attributeNamespace || 'example.com', }; diff --git a/ee/packages/abac/src/pdp/VirtruPDP.ts b/ee/packages/abac/src/pdp/VirtruPDP.ts index 4972b3be48919..6d9d42a4f8773 100644 --- a/ee/packages/abac/src/pdp/VirtruPDP.ts +++ b/ee/packages/abac/src/pdp/VirtruPDP.ts @@ -2,6 +2,7 @@ import type { IAbacAttributeDefinition, IRoom, IUser, AtLeast } from '@rocket.ch import { Rooms, Users } from '@rocket.chat/models'; import { serverFetch } from '@rocket.chat/server-fetch'; import { isTruthy } from '@rocket.chat/tools'; +import mem from 'mem'; import pLimit from 'p-limit'; import { OnlyCompliantCanBeAddedToRoomError } from '../errors'; @@ -22,22 +23,27 @@ import type { const pdpLogger = logger.section('VirtruPDP'); const HEALTH_CHECK_TIMEOUT = 10000; +const HEALTH_CACHE_TTL_MS = Number(process.env.ABAC_HEALTH_CACHE_TTL_MS) || 5 * 60 * 1000; export class VirtruPDP implements IPolicyDecisionPoint { private tokenCache: ITokenCache | null = null; private config: IVirtruPDPConfig; + isAvailable: () => Promise; + constructor(config: IVirtruPDPConfig) { this.config = config; + this.isAvailable = mem(this._checkAvailability.bind(this), { maxAge: HEALTH_CACHE_TTL_MS }); } updateConfig(config: IVirtruPDPConfig): void { this.config = config; this.tokenCache = null; + mem.clear(this.isAvailable); } - async isAvailable(): Promise { + private async _checkAvailability(): Promise { try { const response = await serverFetch(`${this.config.baseUrl}/healthz`, { method: 'GET', From 902a547a04053b6a15d19127625b4c206dac0dd6 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 7 Apr 2026 13:08:09 -0600 Subject: [PATCH 34/41] fix: Revert caching for availability check --- docker-compose-ci.yml | 1 - ee/packages/abac/src/pdp/VirtruPDP.ts | 8 +------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/docker-compose-ci.yml b/docker-compose-ci.yml index 618975a3d2106..32e31b448f9b4 100644 --- a/docker-compose-ci.yml +++ b/docker-compose-ci.yml @@ -56,7 +56,6 @@ services: SERVICE: authorization-service image: ghcr.io/${LOWERCASE_REPOSITORY}/authorization-service:${DOCKER_TAG} environment: - - ABAC_HEALTH_CACHE_TTL_MS=1 - MONGO_URL=mongodb://mongo:27017/rocketchat?replicaSet=rs0 - 'TRANSPORTER=${TRANSPORTER:-}' - MOLECULER_LOG_LEVEL=info diff --git a/ee/packages/abac/src/pdp/VirtruPDP.ts b/ee/packages/abac/src/pdp/VirtruPDP.ts index 6d9d42a4f8773..4972b3be48919 100644 --- a/ee/packages/abac/src/pdp/VirtruPDP.ts +++ b/ee/packages/abac/src/pdp/VirtruPDP.ts @@ -2,7 +2,6 @@ import type { IAbacAttributeDefinition, IRoom, IUser, AtLeast } from '@rocket.ch import { Rooms, Users } from '@rocket.chat/models'; import { serverFetch } from '@rocket.chat/server-fetch'; import { isTruthy } from '@rocket.chat/tools'; -import mem from 'mem'; import pLimit from 'p-limit'; import { OnlyCompliantCanBeAddedToRoomError } from '../errors'; @@ -23,27 +22,22 @@ import type { const pdpLogger = logger.section('VirtruPDP'); const HEALTH_CHECK_TIMEOUT = 10000; -const HEALTH_CACHE_TTL_MS = Number(process.env.ABAC_HEALTH_CACHE_TTL_MS) || 5 * 60 * 1000; export class VirtruPDP implements IPolicyDecisionPoint { private tokenCache: ITokenCache | null = null; private config: IVirtruPDPConfig; - isAvailable: () => Promise; - constructor(config: IVirtruPDPConfig) { this.config = config; - this.isAvailable = mem(this._checkAvailability.bind(this), { maxAge: HEALTH_CACHE_TTL_MS }); } updateConfig(config: IVirtruPDPConfig): void { this.config = config; this.tokenCache = null; - mem.clear(this.isAvailable); } - private async _checkAvailability(): Promise { + async isAvailable(): Promise { try { const response = await serverFetch(`${this.config.baseUrl}/healthz`, { method: 'GET', From 58e281bd8fa9cfb10999c42bbdccadb48b26c1ae Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 7 Apr 2026 13:58:01 -0600 Subject: [PATCH 35/41] fix: Duplicated health checks --- apps/meteor/ee/server/settings/abac.ts | 2 +- ee/packages/abac/src/index.ts | 11 ++++++++++- ee/packages/abac/src/pdp/VirtruPDP.ts | 23 ----------------------- 3 files changed, 11 insertions(+), 25 deletions(-) diff --git a/apps/meteor/ee/server/settings/abac.ts b/apps/meteor/ee/server/settings/abac.ts index 3641db57e004c..6dcbaf93c2f83 100644 --- a/apps/meteor/ee/server/settings/abac.ts +++ b/apps/meteor/ee/server/settings/abac.ts @@ -41,7 +41,7 @@ export function addSettings(): Promise { public: true, section: 'ABAC', invalidValue: 0, - enableQuery: [abacEnabledQuery], + enableQuery: abacEnabledQuery, }); // Virtru PDP Configuration diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 0756e0721a045..06f5e36891b5c 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -623,6 +623,10 @@ export class AbacService extends ServiceClass implements IAbacService { return true; } + if (!(await this.pdp.isAvailable())) { + return false; + } + let decision: { granted: boolean; userToRemove?: IUser }; try { decision = await this.pdp.canAccessObject(room, user); @@ -647,6 +651,7 @@ export class AbacService extends ServiceClass implements IAbacService { return; } + await this.ensurePdpAvailable(); await this.pdp.checkUsernamesMatchAttributes(usernames, attributes, object); usernames.forEach((username) => { @@ -728,6 +733,10 @@ export class AbacService extends ServiceClass implements IAbacService { return; } + if (!(await this.pdp.isAvailable())) { + return; + } + try { const nonCompliantRooms = await this.pdp.onSubjectAttributesChanged(user, _next); @@ -753,7 +762,7 @@ export class AbacService extends ServiceClass implements IAbacService { } async evaluateRoomMembership(): Promise { - if (!this.pdp) { + if (!this.pdp || !(await this.pdp.isAvailable())) { return; } diff --git a/ee/packages/abac/src/pdp/VirtruPDP.ts b/ee/packages/abac/src/pdp/VirtruPDP.ts index 4972b3be48919..e04098d1b6815 100644 --- a/ee/packages/abac/src/pdp/VirtruPDP.ts +++ b/ee/packages/abac/src/pdp/VirtruPDP.ts @@ -253,11 +253,6 @@ export class VirtruPDP implements IPolicyDecisionPoint { return { granted: true }; } - if (!(await this.isAvailable())) { - pdpLogger.warn({ msg: 'Virtru PDP is unavailable, failing closed', roomId: room._id, userId: user._id }); - return { granted: false }; - } - const fullUser = await Users.findOneById(user._id); if (!fullUser) { return { granted: false }; @@ -302,11 +297,6 @@ export class VirtruPDP implements IPolicyDecisionPoint { return; } - if (!(await this.isAvailable())) { - pdpLogger.warn({ msg: 'Virtru PDP is unavailable, failing closed — refusing to add users', roomId: object._id }); - throw new OnlyCompliantCanBeAddedToRoomError(); - } - const users = await Users.findByUsernames(usernames, { projection: { _id: 1, emails: 1, username: 1 } }).toArray(); const fqns = this.buildAttributeFqns(attributes); @@ -410,11 +400,6 @@ export class VirtruPDP implements IPolicyDecisionPoint { rooms: AtLeast[]; }>, ): Promise; room: IRoom }>> { - if (!(await this.isAvailable())) { - pdpLogger.warn({ msg: 'Virtru PDP is unavailable, skipping bulk room membership evaluation — no users will be removed' }); - return []; - } - const requestIndex: Array<{ user: Pick; room: AtLeast }> = []; const allRequests: IGetDecisionBulkRequest[] = []; @@ -468,14 +453,6 @@ export class VirtruPDP implements IPolicyDecisionPoint { return []; } - if (!(await this.isAvailable())) { - pdpLogger.warn({ - msg: 'Virtru PDP is unavailable, skipping subject attributes evaluation — no users will be removed', - userId: user._id, - }); - return []; - } - const entityKey = this.getUserEntityKey(user); if (!entityKey) { pdpLogger.warn({ msg: 'User has no entity key for Virtru PDP evaluation, skipping', userId: user._id }); From 6c5a4ba40d4200db36c145f24ec02bb05f74c019 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 13 Apr 2026 18:14:00 -0600 Subject: [PATCH 36/41] fix accordionitem --- .../client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx index 70f24d9dac9cb..7d834b9075188 100644 --- a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx +++ b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx @@ -39,9 +39,7 @@ const SettingsPage = () => { - - From e75d25773a11e8ade091f3c797bae5654d9120d5 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 14 Apr 2026 12:37:49 -0600 Subject: [PATCH 37/41] Delete .changeset/red-windows-breathe.md --- .changeset/red-windows-breathe.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/red-windows-breathe.md diff --git a/.changeset/red-windows-breathe.md b/.changeset/red-windows-breathe.md deleted file mode 100644 index a177574edea6b..0000000000000 --- a/.changeset/red-windows-breathe.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@rocket.chat/meteor": patch ---- - -Fixes calendar events modifying the wrong status property when attempting to sync `busy` status. From 017061390d2320b4a3d16bb424889c23eb1c7821 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 14 Apr 2026 13:53:10 -0600 Subject: [PATCH 38/41] fix --- ee/packages/abac/src/pdp/VirtruPDP.ts | 46 +++++++++++++-------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/ee/packages/abac/src/pdp/VirtruPDP.ts b/ee/packages/abac/src/pdp/VirtruPDP.ts index e04098d1b6815..0cd9b718c90db 100644 --- a/ee/packages/abac/src/pdp/VirtruPDP.ts +++ b/ee/packages/abac/src/pdp/VirtruPDP.ts @@ -1,7 +1,7 @@ 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 { OnlyCompliantCanBeAddedToRoomError } from '../errors'; @@ -300,29 +300,29 @@ export class VirtruPDP implements IPolicyDecisionPoint { const users = await Users.findByUsernames(usernames, { projection: { _id: 1, emails: 1, username: 1 } }).toArray(); const fqns = this.buildAttributeFqns(attributes); - const decisionRequests = users - .map((user) => { - const entityKey = this.getUserEntityKey(user); - if (!entityKey) { - return null; - } - - return { - entityIdentifier: { - entityChain: { - entities: [this.buildEntityIdentifier(entityKey)], - }, + 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 }, - }, - ], - }; - }) - .filter(isTruthy); + }, + action: { name: 'read' }, + resources: [ + { + ephemeralId: object._id, + attributeValues: { fqns }, + }, + ], + }); + } if (!decisionRequests.length) { throw new OnlyCompliantCanBeAddedToRoomError(); From e96bb1a8fe9a1e292a26be9e6c4480fbf7f7be2f Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 14 Apr 2026 13:59:17 -0600 Subject: [PATCH 39/41] lint --- ee/packages/abac/src/pdp/VirtruPDP.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/ee/packages/abac/src/pdp/VirtruPDP.ts b/ee/packages/abac/src/pdp/VirtruPDP.ts index 0cd9b718c90db..f8310d4bac3bf 100644 --- a/ee/packages/abac/src/pdp/VirtruPDP.ts +++ b/ee/packages/abac/src/pdp/VirtruPDP.ts @@ -1,7 +1,6 @@ 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 } from '../errors'; From d7bd7aa43f7edca05046403a3d54ef87fb2e0e12 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 14 Apr 2026 14:42:26 -0600 Subject: [PATCH 40/41] rage against the machine --- apps/meteor/ee/server/api/abac/index.ts | 4 +- ee/packages/abac/src/errors.ts | 11 +++++ ee/packages/abac/src/index.ts | 4 +- ee/packages/abac/src/pdp/VirtruPDP.ts | 53 +++++++++++++++---------- packages/i18n/src/locales/en.i18n.json | 1 + 5 files changed, 51 insertions(+), 22 deletions(-) diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index b7fa699ca8c12..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'; @@ -380,7 +381,8 @@ const abacEndpoints = API.v1 await Abac.getPDPHealth(); return API.v1.success({ available: true, message: 'ABAC_PDP_Health_OK' }); } catch (err) { - return API.v1.failure({ available: false, message: (err as Error).message }); + const message = err instanceof PdpHealthCheckError ? err.errorCode : 'ABAC_PDP_Health_Not_OK'; + return API.v1.failure({ available: false, message }); } }, ) diff --git a/ee/packages/abac/src/errors.ts b/ee/packages/abac/src/errors.ts index 6724fcae13fb5..cb9dd22890d0f 100644 --- a/ee/packages/abac/src/errors.ts +++ b/ee/packages/abac/src/errors.ts @@ -98,3 +98,14 @@ export class PdpUnavailableError extends AbacError { 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 06f5e36891b5c..e260662517de7 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -25,6 +25,7 @@ import { AbacUnsupportedObjectTypeError, AbacUnsupportedOperationError, PdpUnavailableError, + PdpHealthCheckError, } from './errors'; import { getAbacRoom, @@ -755,7 +756,7 @@ export class AbacService extends ServiceClass implements IAbacService { async getPDPHealth(): Promise { if (!this.pdp) { - throw new Error('ABAC_PDP_Health_No_PDP'); + throw new PdpHealthCheckError('ABAC_PDP_Health_No_PDP'); } await this.pdp.getHealthStatus(); @@ -807,5 +808,6 @@ export class AbacService extends ServiceClass implements IAbacService { 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/VirtruPDP.ts b/ee/packages/abac/src/pdp/VirtruPDP.ts index f8310d4bac3bf..3026214cb5ec8 100644 --- a/ee/packages/abac/src/pdp/VirtruPDP.ts +++ b/ee/packages/abac/src/pdp/VirtruPDP.ts @@ -3,7 +3,7 @@ import { Rooms, Users } from '@rocket.chat/models'; import { serverFetch } from '@rocket.chat/server-fetch'; import pLimit from 'p-limit'; -import { OnlyCompliantCanBeAddedToRoomError } from '../errors'; +import { OnlyCompliantCanBeAddedToRoomError, PdpHealthCheckError } from '../errors'; import { logger } from '../logger'; import type { Decision, @@ -20,7 +20,8 @@ import type { const pdpLogger = logger.section('VirtruPDP'); -const HEALTH_CHECK_TIMEOUT = 10000; +const HEALTH_CHECK_TIMEOUT = 5000; +const REQUEST_TIMEOUT = 10000; export class VirtruPDP implements IPolicyDecisionPoint { private tokenCache: ITokenCache | null = null; @@ -40,6 +41,7 @@ export class VirtruPDP implements IPolicyDecisionPoint { 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, }); @@ -69,7 +71,7 @@ export class VirtruPDP implements IPolicyDecisionPoint { this.tokenCache = null; return await this.getClientToken(); } catch { - throw new Error('ABAC_PDP_Health_IdP_Failed'); + throw new PdpHealthCheckError('ABAC_PDP_Health_IdP_Failed'); } } @@ -91,7 +93,7 @@ export class VirtruPDP implements IPolicyDecisionPoint { throw new Error(); } } catch { - throw new Error('ABAC_PDP_Health_Platform_Failed'); + throw new PdpHealthCheckError('ABAC_PDP_Health_Platform_Failed'); } } @@ -110,7 +112,7 @@ export class VirtruPDP implements IPolicyDecisionPoint { throw new Error(); } } catch { - throw new Error('ABAC_PDP_Health_Authorization_Failed'); + throw new PdpHealthCheckError('ABAC_PDP_Health_Authorization_Failed'); } } @@ -120,6 +122,7 @@ export class VirtruPDP implements IPolicyDecisionPoint { } 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', @@ -151,6 +154,7 @@ export class VirtruPDP implements IPolicyDecisionPoint { const response = await serverFetch(`${this.config.baseUrl}${endpoint}`, { method: 'POST', + timeout: REQUEST_TIMEOUT, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, @@ -329,7 +333,9 @@ export class VirtruPDP implements IPolicyDecisionPoint { const responses = await this.getDecisionBulk(decisionRequests); - const hasNonCompliant = responses.some((resp) => resp?.resourceDecisions?.some((rd) => rd.decision !== 'DECISION_PERMIT')); + const hasNonCompliant = responses.some( + (resp) => !resp?.resourceDecisions?.length || resp.resourceDecisions.some((rd) => rd.decision !== 'DECISION_PERMIT'), + ); if (hasNonCompliant) { throw new OnlyCompliantCanBeAddedToRoomError(); @@ -356,7 +362,8 @@ export class VirtruPDP implements IPolicyDecisionPoint { 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, skipping', userId: user._id }); + pdpLogger.warn({ msg: 'User has no entity key for Virtru PDP evaluation, treating as non-compliant', userId: user._id }); + nonCompliantUsers.push(user); continue; } @@ -384,7 +391,7 @@ export class VirtruPDP implements IPolicyDecisionPoint { const responses = await this.getDecisionBulk(decisionRequests); responses.forEach((resp, index) => { - const permitted = resp?.resourceDecisions?.every((rd) => rd.decision === 'DECISION_PERMIT'); + const permitted = resp?.resourceDecisions?.length && resp.resourceDecisions.every((rd) => rd.decision === 'DECISION_PERMIT'); if (!permitted && requestUserIndex[index]) { nonCompliantUsers.push(requestUserIndex[index]); } @@ -402,10 +409,15 @@ export class VirtruPDP implements IPolicyDecisionPoint { 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, skipping', userId: user._id }); + 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; } @@ -429,15 +441,13 @@ export class VirtruPDP implements IPolicyDecisionPoint { } if (!allRequests.length) { - return []; + return nonCompliant; } const responses = await this.getDecisionBulk(allRequests); - const nonCompliant: Array<{ user: Pick; room: IRoom }> = []; - responses.forEach((resp, index) => { - const permitted = resp?.resourceDecisions?.every((rd) => rd.decision === 'DECISION_PERMIT'); + 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 }); } @@ -452,12 +462,6 @@ export class VirtruPDP implements IPolicyDecisionPoint { return []; } - const entityKey = this.getUserEntityKey(user); - if (!entityKey) { - pdpLogger.warn({ msg: 'User has no entity key for Virtru PDP evaluation, skipping', userId: user._id }); - return []; - } - const abacRooms = await Rooms.findPrivateRoomsByIdsWithAbacAttributes(roomIds, { projection: { _id: 1, abacAttributes: 1 }, }).toArray(); @@ -466,6 +470,15 @@ export class VirtruPDP implements IPolicyDecisionPoint { 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: { @@ -486,7 +499,7 @@ export class VirtruPDP implements IPolicyDecisionPoint { const nonCompliantRooms: IRoom[] = []; responses.forEach((resp, index) => { - const permitted = resp?.resourceDecisions?.every((rd) => rd.decision === 'DECISION_PERMIT'); + const permitted = resp?.resourceDecisions?.length && resp.resourceDecisions.every((rd) => rd.decision === 'DECISION_PERMIT'); if (!permitted && abacRooms[index]) { nonCompliantRooms.push(abacRooms[index]); } diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 21c072a627261..47329cf6f7e22 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -17,6 +17,7 @@ "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)", From a45c6c805ab74fbf661a82d9905ace59b252c213 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 14 Apr 2026 15:10:49 -0600 Subject: [PATCH 41/41] pls --- ee/packages/abac/src/index.ts | 50 +++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index e260662517de7..cf8e6aae68a47 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -66,9 +66,15 @@ export class AbacService extends ServiceClass implements IAbacService { this.onSettingChanged('ABAC_PDP_Type', async ({ setting }): Promise => { const { value } = setting; - if (value === 'local' || value === 'virtru') { - this.setPdpStrategy(value); + 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 => { @@ -110,6 +116,26 @@ export class AbacService extends ServiceClass implements IAbacService { }); } + 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 }); @@ -148,24 +174,7 @@ export class AbacService extends ServiceClass implements IAbacService { return; } - 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', - }; - + await this.loadVirtruPdpConfig(); this.setPdpStrategy('virtru'); } @@ -493,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 || [];