diff --git a/apps/meteor/ee/server/configuration/ldap.ts b/apps/meteor/ee/server/configuration/ldap.ts index 055401f40b053..7bf2d39e7f387 100644 --- a/apps/meteor/ee/server/configuration/ldap.ts +++ b/apps/meteor/ee/server/configuration/ldap.ts @@ -60,14 +60,23 @@ Meteor.startup(async () => { () => LDAPEE.syncLogout(), ); + const addAbacCronJob = configureBackgroundSync( + 'LDAP_AbacSync', + 'LDAP_Background_Sync_ABAC_Attributes', + 'LDAP_Background_Sync_ABAC_Attributes_Interval', + () => LDAPEE.syncAbacAttributes(), + ); + settings.watchMultiple(['LDAP_Background_Sync', 'LDAP_Background_Sync_Interval'], addCronJob); settings.watchMultiple(['LDAP_Background_Sync_Avatars', 'LDAP_Background_Sync_Avatars_Interval'], addAvatarCronJob); settings.watchMultiple(['LDAP_Sync_AutoLogout_Enabled', 'LDAP_Sync_AutoLogout_Interval'], addLogoutCronJob); + settings.watchMultiple(['LDAP_Background_Sync_ABAC_Attributes', 'LDAP_Background_Sync_ABAC_Attributes_Interval'], addAbacCronJob); settings.watch('LDAP_Enable', async () => { await addCronJob(); await addAvatarCronJob(); await addLogoutCronJob(); + await addAbacCronJob(); }); settings.watch('LDAP_Groups_To_Rocket_Chat_Teams', (value) => { @@ -78,6 +87,14 @@ Meteor.startup(async () => { } }); + settings.watch('LDAP_ABAC_AttributeMap', (value) => { + try { + LDAPEEManager.validateLDAPABACAttributeMap(value); + } catch (error) { + logger.error(error); + } + }); + callbacks.add( 'mapLDAPUserData', (userData: IImportUser, ldapUser?: ILDAPEntry) => { diff --git a/apps/meteor/ee/server/lib/ldap/Manager.ts b/apps/meteor/ee/server/lib/ldap/Manager.ts index b15dfc6d34335..07645249476e7 100644 --- a/apps/meteor/ee/server/lib/ldap/Manager.ts +++ b/apps/meteor/ee/server/lib/ldap/Manager.ts @@ -1,5 +1,6 @@ -import { Team } from '@rocket.chat/core-services'; +import { Abac, Team } from '@rocket.chat/core-services'; import type { ILDAPEntry, IUser, IRoom, IRole, IImportUser, IImportRecord } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; import { Users, Roles, Subscriptions as SubscriptionsRaw, Rooms } from '@rocket.chat/models'; import type ldapjs from 'ldapjs'; @@ -102,6 +103,30 @@ export class LDAPEEManager extends LDAPManager { } } + public static async syncAbacAttributes(): Promise { + if ( + !settings.get('LDAP_Enable') || + !settings.get('LDAP_Background_Sync_ABAC_Attributes') || + !License.hasModule('abac') || + !settings.get('ABAC_Enabled') + ) { + return; + } + + try { + const ldap = new LDAPConnection(); + await ldap.connect(); + + try { + await this.updateUserAbacAttributes(ldap); + } finally { + ldap.disconnect(); + } + } catch (error) { + logger.error(error); + } + } + public static validateLDAPTeamsMappingChanges(json: string): void { if (!json) { return; @@ -123,6 +148,32 @@ export class LDAPEEManager extends LDAPManager { } } + public static validateLDAPABACAttributeMap(json: string): void { + if (!json) { + return; + } + + const mappedAttributes = this.parseJson(json); + + // attributes are { key: value } with key being the ldap attribute and value being the abac attribute in rocketchat + // both strings + // There's no need for the attribute to exist in rocketchat, we just add whatever the admin wants to map + + if (!mappedAttributes || Object.keys(mappedAttributes).length === 0) { + return; + } + + const validStructureMapping = Object.entries(mappedAttributes).every( + ([key, value]) => typeof key === 'string' && typeof value === 'string', + ); + + if (!validStructureMapping) { + throw new Error( + 'Please verify your mapping for LDAP X RocketChat ABAC Attributes. The structure is invalid, the structure should be an object like: {key: LdapAttribute, value: RocketChatAbacAttribute}', + ); + } + } + public static async syncLogout(): Promise { if (settings.get('LDAP_Enable') !== true || settings.get('LDAP_Sync_AutoLogout_Enabled') !== true) { return; @@ -665,6 +716,23 @@ export class LDAPEEManager extends LDAPManager { } } + private static async updateUserAbacAttributes(ldap: LDAPConnection): Promise { + const mapping = this.parseJson(settings.get('LDAP_ABAC_AttributeMap')); + if (!mapping) { + logger.error('LDAP to ABAC attribute mapping is not valid JSON'); + return; + } + + for await (const user of Users.findLDAPUsers()) { + const ldapUser = await this.findLDAPUser(ldap, user); + if (!ldapUser) { + continue; + } + + await Abac.addSubjectAttributes(user, ldapUser, mapping); + } + } + private static async findLDAPUser(ldap: LDAPConnection, user: IUser): Promise { if (user.services?.ldap?.id) { return ldap.findOneById(user.services.ldap.id, user.services.ldap.idAttribute); diff --git a/apps/meteor/ee/server/local-services/ldap/service.ts b/apps/meteor/ee/server/local-services/ldap/service.ts index 3c38152ed22cd..cd08a7db34438 100644 --- a/apps/meteor/ee/server/local-services/ldap/service.ts +++ b/apps/meteor/ee/server/local-services/ldap/service.ts @@ -17,4 +17,8 @@ export class LDAPEEService extends ServiceClassInternal implements ILDAPEEServic async syncLogout(): Promise { return LDAPEEManager.syncLogout(); } + + async syncAbacAttributes(): Promise { + return LDAPEEManager.syncAbacAttributes(); + } } diff --git a/apps/meteor/ee/server/sdk/types/ILDAPEEService.ts b/apps/meteor/ee/server/sdk/types/ILDAPEEService.ts index 246d52884a8f6..dad7c23f0a2f1 100644 --- a/apps/meteor/ee/server/sdk/types/ILDAPEEService.ts +++ b/apps/meteor/ee/server/sdk/types/ILDAPEEService.ts @@ -2,4 +2,5 @@ export interface ILDAPEEService { sync(): Promise; syncAvatars(): Promise; syncLogout(): Promise; + syncAbacAttributes(): Promise; } diff --git a/apps/meteor/ee/server/settings/ldap.ts b/apps/meteor/ee/server/settings/ldap.ts index d026d913be9dd..e2417e7622ac4 100644 --- a/apps/meteor/ee/server/settings/ldap.ts +++ b/apps/meteor/ee/server/settings/ldap.ts @@ -282,6 +282,30 @@ export function addSettings(): Promise { invalidValue: '', }); }); + + await this.section('LDAP_DataSync_ABAC', async function () { + await this.add('LDAP_Background_Sync_ABAC_Attributes', false, { + type: 'boolean', + enableQuery, + invalidValue: false, + modules: ['abac', 'ldap-enterprise'], + }); + + await this.add('LDAP_Background_Sync_ABAC_Attributes_Interval', '0 0 * * *', { + type: 'string', + enableQuery: [enableQuery, { _id: 'LDAP_Background_Sync_ABAC_Attributes', value: true }], + invalidValue: '0 0 * * *', + modules: ['abac', 'ldap-enterprise'], + }); + + await this.add('LDAP_ABAC_AttributeMap', '{}', { + type: 'code', + multiline: true, + enableQuery: [enableQuery, { _id: 'LDAP_Background_Sync_ABAC_Attributes', value: true }], + invalidValue: '{}', + modules: ['abac', 'ldap-enterprise'], + }); + }); }, ); }); diff --git a/ee/packages/abac/src/helper.ts b/ee/packages/abac/src/helper.ts new file mode 100644 index 0000000000000..283e83514f195 --- /dev/null +++ b/ee/packages/abac/src/helper.ts @@ -0,0 +1,34 @@ +import type { ILDAPEntry, IAbacAttributeDefinition } from '@rocket.chat/core-typings'; + +export const extractAttribute = (ldapUser: ILDAPEntry, ldapKey: string, abacKey: string): IAbacAttributeDefinition | undefined => { + if (!ldapKey || !abacKey) { + return; + } + const raw = ldapUser?.[ldapKey]; + if (!raw) { + return; + } + const valuesSet = new Set(); + const addIfValid = (value: unknown) => { + if (typeof value !== 'string') { + return; + } + const trimmed = value.trim(); + if (!trimmed.length) { + return; + } + valuesSet.add(trimmed); + }; + + if (Array.isArray(raw)) { + for (const v of raw) { + addIfValid(v); + } + } else { + addIfValid(raw); + } + if (!valuesSet.size) { + return; + } + return { key: abacKey, values: Array.from(valuesSet) }; +}; diff --git a/ee/packages/abac/src/index.spec.ts b/ee/packages/abac/src/index.spec.ts index 1a55e284bfc80..2f7b0c4b6de19 100644 --- a/ee/packages/abac/src/index.spec.ts +++ b/ee/packages/abac/src/index.spec.ts @@ -1,3 +1,5 @@ +import type { IAbacAttributeDefinition } from '@rocket.chat/core-typings'; + import { AbacService } from './index'; const mockFindOneByIdAndType = jest.fn(); @@ -15,6 +17,7 @@ const mockUpdateAbacAttributeValuesArrayFilteredById = jest.fn(); const mockRemoveAbacAttributeByRoomIdAndKey = jest.fn(); const mockInsertAbacAttributeIfNotExistsById = jest.fn(); const mockUsersFind = jest.fn(); +const mockUsersUpdateOne = jest.fn(); jest.mock('@rocket.chat/models', () => ({ Rooms: { @@ -40,6 +43,7 @@ jest.mock('@rocket.chat/models', () => ({ }, Users: { find: (...args: any[]) => mockUsersFind(...args), + updateOne: (...args: any[]) => mockUsersUpdateOne(...args), }, })); @@ -63,6 +67,311 @@ describe('AbacService (unit)', () => { jest.clearAllMocks(); }); + describe('addSubjectAttributes (merging behavior)', () => { + const getUpdatedAttributesFromCall = () => { + const call = mockUsersUpdateOne.mock.calls.find((c) => c[1]?.$set?.abacAttributes); + return call?.[1].$set.abacAttributes as any[] | undefined; + }; + + it('merges values from multiple LDAP keys mapping to the same ABAC key', async () => { + const user = { _id: 'u1' } as any; + const ldapUser = { + memberOf: ['eng', 'sales'], + department: ['sales', 'support'], + } as any; + + const map = { + memberOf: 'dept', + department: 'dept', + }; + + await service.addSubjectAttributes(user, ldapUser, map); + + expect(mockUsersUpdateOne).toHaveBeenCalledTimes(1); + const final = getUpdatedAttributesFromCall(); + expect(final).toBeDefined(); + expect(final).toHaveLength(1); + expect(final?.[0].key).toBe('dept'); + expect(final?.[0].values).toEqual(['eng', 'sales', 'support']); + }); + + it('deduplicates values across different LDAP keys and within arrays', async () => { + const user = { _id: 'u2' } as any; + const ldapUser = { + group: ['alpha', 'beta', 'alpha'], + team: ['beta', 'gamma'], + role: 'gamma', + } as any; + + const map = { + group: 'combined', + team: 'combined', + role: 'combined', + }; + + await service.addSubjectAttributes(user, ldapUser, map); + + const final = getUpdatedAttributesFromCall(); + expect(final?.[0].values).toEqual(['alpha', 'beta', 'gamma']); + }); + + it('unsets abacAttributes when no LDAP values are found and user previously had attributes', async () => { + const user = { + _id: 'u3', + abacAttributes: [{ key: 'dept', values: ['eng'] }], + } as any; + const ldapUser = { + other: ['x'], + } as any; + + const map = { + memberOf: 'dept', + }; + + await service.addSubjectAttributes(user, ldapUser, map); + + const unsetCall = mockUsersUpdateOne.mock.calls.find((c) => c[1]?.$unset?.abacAttributes); + expect(unsetCall).toBeDefined(); + }); + + it('does nothing when no LDAP values are found and user had no previous attributes', async () => { + const user = { _id: 'u4' } as any; + const ldapUser = {} as any; + const map = { missing: 'dept' }; + + await service.addSubjectAttributes(user, ldapUser, map); + + expect(mockUsersUpdateOne).not.toHaveBeenCalled(); + }); + + it('calls onSubjectAttributesChanged when user loses an attribute value', async () => { + const user = { + _id: 'u5', + abacAttributes: [{ key: 'dept', values: ['eng', 'qa'] }], + } as any; + const ldapUser = { + memberOf: ['eng'], + } as any; + const map = { memberOf: 'dept' }; + + const spy = jest.spyOn(service as any, 'onSubjectAttributesChanged'); + + await service.addSubjectAttributes(user, ldapUser, map); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][1]).toEqual([{ key: 'dept', values: ['eng'] }]); + }); + + it('does not call onSubjectAttributesChanged when only gaining new values', async () => { + const user = { + _id: 'u6', + abacAttributes: [{ key: 'dept', values: ['eng'] }], + } as any; + const ldapUser = { + memberOf: ['eng', 'qa'], + } as any; + const map = { memberOf: 'dept' }; + + const spy = jest.spyOn(service as any, 'onSubjectAttributesChanged'); + + await service.addSubjectAttributes(user, ldapUser, map); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('calls onSubjectAttributesChanged when an entire attribute key is lost', async () => { + const user = { + _id: 'u7', + abacAttributes: [ + { key: 'dept', values: ['eng'] }, + { key: 'region', values: ['emea'] }, + ], + } as any; + const ldapUser = { + department: ['eng'], + } as any; + const map = { department: 'dept' }; + + const spy = jest.spyOn(service as any, 'onSubjectAttributesChanged'); + + await service.addSubjectAttributes(user, ldapUser, map); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][1]).toEqual([{ key: 'dept', values: ['eng'] }]); + }); + + it('supports mixing array and string LDAP values merging into one ABAC attribute', async () => { + const user = { _id: 'u8' } as any; + const ldapUser = { + deptCode: 'eng', + deptName: ['engineering', 'eng'], + } as any; + const map = { deptCode: 'dept', deptName: 'dept' }; + + await service.addSubjectAttributes(user, ldapUser, map); + + const final = getUpdatedAttributesFromCall(); + expect(final?.[0].key).toBe('dept'); + expect(final?.[0].values).toEqual(['eng', 'engineering']); + }); + + it('ignores empty string values and unsets when all values invalid and user had attributes', async () => { + const user = { _id: 'u9', abacAttributes: [{ key: 'dept', values: ['eng'] }] } as any; + const ldapUser = { + memberOf: ['', ' ', null], + department: '', + } as any; + const map = { memberOf: 'dept', department: 'dept' }; + + const spy = jest.spyOn(service as any, 'onSubjectAttributesChanged'); + await service.addSubjectAttributes(user, ldapUser, map); + + const unsetCall = mockUsersUpdateOne.mock.calls.find((c) => c[1]?.$unset?.abacAttributes); + expect(unsetCall).toBeDefined(); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][1]).toEqual([]); + }); + }); + + describe('didSubjectLoseAttributes', () => { + const call = (previous: IAbacAttributeDefinition[], next: IAbacAttributeDefinition[]) => + (service as any).didSubjectLoseAttributes(previous, next) as boolean; + + it('returns false if previous is empty (no attributes to lose)', () => { + expect(call([], [])).toBe(false); + expect(call([], [{ key: 'dept', values: ['engineering'] }])).toBe(false); + }); + + it('returns false if all previous attributes and values are preserved', () => { + const prev: IAbacAttributeDefinition[] = [ + { key: 'dept', values: ['engineering', 'qa'] }, + { key: 'role', values: ['admin'] }, + ]; + const next: IAbacAttributeDefinition[] = [ + { key: 'dept', values: ['engineering', 'qa'] }, + { key: 'role', values: ['admin'] }, + ]; + expect(call(prev, next)).toBe(false); + }); + + it('returns false if previous values are a subset of next values (nothing lost)', () => { + const prev: IAbacAttributeDefinition[] = [{ key: 'dept', values: ['engineering'] }]; + const next: IAbacAttributeDefinition[] = [{ key: 'dept', values: ['engineering', 'qa'] }]; + expect(call(prev, next)).toBe(false); + }); + + it('returns true if an entire previous attribute key is missing in next', () => { + const prev: IAbacAttributeDefinition[] = [ + { key: 'dept', values: ['engineering'] }, + { key: 'role', values: ['admin'] }, + ]; + const next: IAbacAttributeDefinition[] = [{ key: 'dept', values: ['engineering'] }]; + expect(call(prev, next)).toBe(true); + }); + + it('returns true if any previous value is missing from corresponding next attribute', () => { + const prev: IAbacAttributeDefinition[] = [{ key: 'dept', values: ['engineering', 'qa'] }]; + const next: IAbacAttributeDefinition[] = [{ key: 'dept', values: ['engineering'] }]; + expect(call(prev, next)).toBe(true); + }); + + it('returns true if multiple attributes exist and one loses a value', () => { + const prev: IAbacAttributeDefinition[] = [ + { key: 'dept', values: ['engineering', 'qa'] }, + { key: 'role', values: ['admin'] }, + ]; + const next: IAbacAttributeDefinition[] = [ + { key: 'dept', values: ['engineering'] }, + { key: 'role', values: ['admin'] }, + ]; + expect(call(prev, next)).toBe(true); + }); + + it('returns true when next is empty but previous had attributes (all lost)', () => { + const prev: IAbacAttributeDefinition[] = [{ key: 'dept', values: ['engineering'] }]; + expect(call(prev, [])).toBe(true); + }); + + it('returns true if one attribute key remains but all its values are lost', () => { + const prev: IAbacAttributeDefinition[] = [{ key: 'dept', values: ['engineering'] }]; + const next: IAbacAttributeDefinition[] = [{ key: 'dept', values: [] }]; + expect(call(prev, next)).toBe(true); + }); + + it('returns true on first detected loss even if multiple losses exist', () => { + const prev: IAbacAttributeDefinition[] = [ + { key: 'dept', values: ['engineering'] }, + { key: 'region', values: ['us', 'eu'] }, + ]; + const next: IAbacAttributeDefinition[] = [ + { key: 'dept', values: [] }, + { key: 'region', values: ['us'] }, + ]; + expect(call(prev, next)).toBe(true); + }); + + it('does not mutate input arrays (pure function)', () => { + const prev: IAbacAttributeDefinition[] = [{ key: 'dept', values: ['engineering', 'qa'] }]; + const next: IAbacAttributeDefinition[] = [{ key: 'dept', values: ['engineering', 'qa'] }]; + const prevClone = JSON.parse(JSON.stringify(prev)); + const nextClone = JSON.parse(JSON.stringify(next)); + expect(call(prev, next)).toBe(false); + expect(prev).toEqual(prevClone); + expect(next).toEqual(nextClone); + }); + + it('returns false if ordering of values changes but values remain (order-insensitive)', () => { + const prev: IAbacAttributeDefinition[] = [{ key: 'dept', values: ['engineering', 'qa'] }]; + const next: IAbacAttributeDefinition[] = [{ key: 'dept', values: ['qa', 'engineering'] }]; + expect(call(prev, next)).toBe(false); + }); + + it('returns true if previous attribute key replaced with a different key only', () => { + const prev: IAbacAttributeDefinition[] = [{ key: 'dept', values: ['engineering'] }]; + const next: IAbacAttributeDefinition[] = [{ key: 'role', values: ['admin'] }]; + expect(call(prev, next)).toBe(true); + }); + + it('returns false when next adds a new attribute without removing previous ones', () => { + const prev: IAbacAttributeDefinition[] = [{ key: 'dept', values: ['engineering'] }]; + const next: IAbacAttributeDefinition[] = [ + { key: 'dept', values: ['engineering'] }, + { key: 'role', values: ['admin'] }, + ]; + expect(call(prev, next)).toBe(false); + }); + + it('returns false when attribute keys are reordered but unchanged', () => { + const prev: IAbacAttributeDefinition[] = [ + { key: 'dept', values: ['engineering'] }, + { key: 'role', values: ['admin'] }, + ]; + const next: IAbacAttributeDefinition[] = [ + { key: 'role', values: ['admin'] }, + { key: 'dept', values: ['engineering'] }, + ]; + expect(call(prev, next)).toBe(false); + }); + + it('returns false when duplicate values are present but no unique value is lost', () => { + const prev: IAbacAttributeDefinition[] = [{ key: 'dept', values: ['engineering', 'engineering'] }]; + const next: IAbacAttributeDefinition[] = [{ key: 'dept', values: ['engineering'] }]; + expect(call(prev, next)).toBe(false); + }); + + it('returns false when previous attribute has an empty values array (nothing to lose)', () => { + const prev: IAbacAttributeDefinition[] = [{ key: 'dept', values: [] }]; + const next: IAbacAttributeDefinition[] = [{ key: 'dept', values: [] }]; + expect(call(prev, next)).toBe(false); + }); + + it('returns true when duplicate previous values include one that is lost', () => { + const prev: IAbacAttributeDefinition[] = [{ key: 'dept', values: ['engineering', 'qa', 'qa'] }]; + const next: IAbacAttributeDefinition[] = [{ key: 'dept', values: ['engineering'] }]; + expect(call(prev, next)).toBe(true); + }); + }); + describe('addAbacAttribute', () => { it('inserts attribute when valid', async () => { const attribute = { key: 'Valid_Key-1', values: ['v1', 'v2'] }; diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 0b066e7906331..7513754d8afb2 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -1,13 +1,15 @@ import { MeteorError, Room, ServiceClass } from '@rocket.chat/core-services'; import type { IAbacService } from '@rocket.chat/core-services'; import { AbacAccessOperation, AbacObjectType } from '@rocket.chat/core-typings'; -import type { IAbacAttribute, IAbacAttributeDefinition, IRoom, AtLeast, IUser } from '@rocket.chat/core-typings'; +import type { IAbacAttribute, IAbacAttributeDefinition, IRoom, AtLeast, IUser, ILDAPEntry } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; import { Rooms, AbacAttributes, Users, Subscriptions, Settings } from '@rocket.chat/models'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { Document, UpdateFilter } from 'mongodb'; import pLimit from 'p-limit'; +import { extractAttribute } from './helper'; + // Limit concurrent user removals to avoid overloading the server with too many operations at once const limit = pLimit(20); @@ -21,6 +23,54 @@ export class AbacService extends ServiceClass implements IAbacService { this.logger = new Logger('AbacService'); } + async addSubjectAttributes(user: IUser, ldapUser: ILDAPEntry, map: Record): Promise { + if (!user?._id) { + return; + } + + const entries = Object.entries(map || {}); + + const mergedMap = new Map>(); + for (const [ldapKey, abacKey] of entries) { + const attr = extractAttribute(ldapUser, ldapKey, abacKey); + if (!attr) { + continue; + } + const existing = mergedMap.get(attr.key); + if (!existing) { + mergedMap.set(attr.key, new Set(attr.values)); + continue; + } + for (const v of attr.values) { + existing.add(v); + } + } + const finalAttributes = Array.from(mergedMap.entries()).map(([key, valuesSet]) => ({ + key, + values: Array.from(valuesSet), + })); + + if (!finalAttributes.length) { + if (Array.isArray(user.abacAttributes) && user.abacAttributes.length) { + await Users.updateOne({ _id: user._id }, { $unset: { abacAttributes: 1 } }); + await this.onSubjectAttributesChanged(user, []); + } + return; + } + + await Users.updateOne({ _id: user._id }, { $set: { abacAttributes: finalAttributes } }); + + if (this.didSubjectLoseAttributes(user?.abacAttributes || [], finalAttributes)) { + await this.onSubjectAttributesChanged(user, finalAttributes); + } + + this.logger.debug({ + msg: 'LDAP subject attributes synced to user', + userId: user._id, + finalAttributes, + }); + } + async addAbacAttribute(attribute: IAbacAttributeDefinition): Promise { if (!attribute.values.length) { throw new Error('error-invalid-attribute-values'); @@ -615,6 +665,29 @@ export class AbacService extends ServiceClass implements IAbacService { await Subscriptions.setAbacLastTimeCheckedByUserIdAndRoomId(user._id, room._id, new Date()); return true; } + + private didSubjectLoseAttributes(previous: IAbacAttributeDefinition[], next: IAbacAttributeDefinition[]): boolean { + if (!previous.length) { + return false; + } + const nextMap = new Map(next.map((a) => [a.key, new Set(a.values)])); + for (const prevAttr of previous) { + const nextValues = nextMap.get(prevAttr.key); + if (!nextValues) { + return true; + } + for (const v of prevAttr.values) { + if (!nextValues.has(v)) { + return true; + } + } + } + return false; + } + + protected async onSubjectAttributesChanged(_user: IUser, _next: IAbacAttributeDefinition[]): Promise { + // no-op (hook point for when a user loses an ABAC attribute or value) + } } export default AbacService; diff --git a/packages/core-services/src/types/IAbacService.ts b/packages/core-services/src/types/IAbacService.ts index 26da77c7ff5b0..69bc2e018e6e2 100644 --- a/packages/core-services/src/types/IAbacService.ts +++ b/packages/core-services/src/types/IAbacService.ts @@ -5,6 +5,7 @@ import type { IUser, AbacAccessOperation, AbacObjectType, + ILDAPEntry, } from '@rocket.chat/core-typings'; export interface IAbacService { @@ -31,4 +32,5 @@ export interface IAbacService { action: AbacAccessOperation, objectType: AbacObjectType, ): Promise; + addSubjectAttributes(user: IUser, ldapUser: ILDAPEntry, map: Record): Promise; } diff --git a/packages/core-typings/src/IUser.ts b/packages/core-typings/src/IUser.ts index f725b2738cf92..cde26e1218a0b 100644 --- a/packages/core-typings/src/IUser.ts +++ b/packages/core-typings/src/IUser.ts @@ -1,3 +1,4 @@ +import type { IAbacAttributeDefinition } from './IAbacAttribute'; import type { IRocketChatRecord } from './IRocketChatRecord'; import type { IRole } from './IRole'; import type { Serialized } from './Serialized'; @@ -252,6 +253,8 @@ export interface IUser extends IRocketChatRecord { roomRolePriorities?: Record; isOAuthUser?: boolean; // client only field __rooms?: string[]; + + abacAttributes?: IAbacAttributeDefinition[]; } export interface IRegisterUser extends IUser { diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 2570053dbdce3..51784f77c5706 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -2778,6 +2778,12 @@ "LDAP_Background_Sync_Keep_Existant_Users_Updated_Description": "Will sync the avatar, fields, username, etc (based on your configuration) of all users already imported from LDAP on every **Sync Interval**", "LDAP_Background_Sync_Merge_Existent_Users": "Background Sync Merge Existing Users", "LDAP_Background_Sync_Merge_Existent_Users_Description": "Will merge all users (based on your filter criteria) that exist in LDAP and also exist in Rocket.Chat. To enable this, activate the 'Merge Existing Users' setting in the Data Sync tab.", + "LDAP_DataSync_ABAC": "Sync ABAC Attributes", + "LDAP_Background_Sync_ABAC_Attributes": "ABAC Attributes Background Sync", + "LDAP_Background_Sync_ABAC_Attributes_Description": "Enable a separate background process to sync user ABAC attributes.", + "LDAP_Background_Sync_ABAC_Attributes_Interval": "ABAC Attributes Background Sync Interval", + "LDAP_ABAC_AttributeMap": "ABAC Attribute Mapping", + "LDAP_ABAC_AttributeMap_Description": "Map LDAP user attributes to Rocket.Chat ABAC attributes. \n As an example, `{\"department\":\"dept\", \"region\":\"region\"}` will map the LDAP attribute `department` to the ABAC attribute `dept` and `region` to `region`. \n The structure must be a JSON object where each key is an LDAP attribute name and each value is the ABAC attribute name to set on the user.", "LDAP_BaseDN": "Base DN", "LDAP_BaseDN_Description": "The fully qualified Distinguished Name (DN) of an LDAP subtree you want to search for users and groups. You can add as many as you like; however, each group must be defined in the same domain base as the users that belong to it. Example: `ou=Users+ou=Projects,dc=Example,dc=com`. If you specify restricted user groups, only users that belong to those groups will be in scope. We recommend that you specify the top level of your LDAP directory tree as your domain base and use search filter to control access.", "LDAP_CA_Cert": "CA Cert", diff --git a/yarn.lock b/yarn.lock index fda8b01526ef1..a821347da182d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -29801,6 +29801,15 @@ __metadata: languageName: node linkType: hard +"p-limit@npm:3.1.0, p-limit@npm:^3.0.1, p-limit@npm:^3.0.2, p-limit@npm:^3.1.0": + version: 3.1.0 + resolution: "p-limit@npm:3.1.0" + dependencies: + yocto-queue: "npm:^0.1.0" + checksum: 10/7c3690c4dbf62ef625671e20b7bdf1cbc9534e83352a2780f165b0d3ceba21907e77ad63401708145ca4e25bfc51636588d89a8c0aeb715e6c37d1c066430360 + languageName: node + linkType: hard + "p-limit@npm:^2.0.0, p-limit@npm:^2.2.0": version: 2.3.0 resolution: "p-limit@npm:2.3.0" @@ -38360,6 +38369,16 @@ __metadata: languageName: node linkType: hard +"yauzl@npm:^3.2.0": + version: 3.2.0 + resolution: "yauzl@npm:3.2.0" + dependencies: + buffer-crc32: "npm:~0.2.3" + pend: "npm:~1.2.0" + checksum: 10/a3cd2bfcf7590673bb35750f2a4e5107e3cc939d32d98a072c0673fe42329e390f471b4a53dbbd72512229099b18aa3b79e6ddb87a73b3a17446080c903a2c4b + languageName: node + linkType: hard + "yn@npm:3.1.1": version: 3.1.1 resolution: "yn@npm:3.1.1"