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