diff --git a/apps/meteor/app/lib/server/functions/getFullUserData.ts b/apps/meteor/app/lib/server/functions/getFullUserData.ts index f66f8ecb49c66..9c3eb6a10ef0f 100644 --- a/apps/meteor/app/lib/server/functions/getFullUserData.ts +++ b/apps/meteor/app/lib/server/functions/getFullUserData.ts @@ -22,6 +22,7 @@ const defaultFields = { extension: 1, federated: 1, statusLivechat: 1, + abacAttributes: 1, } as const; const fullFields = { diff --git a/apps/meteor/tests/end-to-end/api/abac.ts b/apps/meteor/tests/end-to-end/api/abac.ts index 4d7d12fb7e646..af1990caf603f 100644 --- a/apps/meteor/tests/end-to-end/api/abac.ts +++ b/apps/meteor/tests/end-to-end/api/abac.ts @@ -5,6 +5,7 @@ import { before, after, describe, it } from 'mocha'; import { MongoClient } from 'mongodb'; import { getCredentials, request, credentials } from '../../data/api-data'; +import { sleep } from '../../data/livechat/utils'; import { updatePermission, updateSetting } from '../../data/permissions.helper'; import { createRoom, deleteRoom } from '../../data/rooms.helper'; import { deleteTeam } from '../../data/teams.helper'; @@ -2032,4 +2033,259 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I }); }); }); + + describe('LDAP integration', () => { + before(async () => { + await Promise.all([ + updateSetting('LDAP_Enable', true), + updateSetting('ABAC_Enabled', true), + updateSetting('LDAP_Background_Sync', true), + updateSetting('LDAP_Background_Sync_Import_New_Users', true), + updateSetting('LDAP_Host', 'openldap'), + updateSetting('LDAP_Port', 1389), + updateSetting('LDAP_Authentication', true), + updateSetting('LDAP_Authentication_UserDN', 'cn=admin,dc=space,dc=air'), + updateSetting('LDAP_Authentication_Password', 'adminpassword'), + updateSetting('LDAP_BaseDN', 'ou=users,dc=space,dc=air'), + updateSetting('LDAP_AD_User_Search_Field', 'uid'), + updateSetting('LDAP_AD_Username_Field', 'uid'), + updateSetting('LDAP_Background_Sync_ABAC_Attributes', true), + updateSetting('LDAP_Background_Sync_ABAC_Attributes_Interval', '0 0 * * *'), + updateSetting( + 'LDAP_ABAC_AttributeMap', + JSON.stringify({ + departmentNumber: 'department', + telephoneNumber: 'phone', + }), + ), + ]); + }); + + before(async function () { + this.timeout(10000); + // Wait for background sync to run once before tests start + await request.post(`${v1}/ldap.syncNow`).set(credentials); + await sleep(5000); + + // Force abac attribute sync for user john.young, that way we test it too :p + await request + .post(`${v1}/abac/users/sync`) + .set(credentials) + .send({ emails: ['john.young@space.air'] }); + + await sleep(2000); + }); + + it('should sync LDAP user john.young with mapped ABAC attributes', async () => { + const res = await request.get(`${v1}/users.info`).set(credentials).query({ username: 'john.young' }).expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('user'); + const user = res.body.user as IUser; + + expect(user).to.have.property('abacAttributes'); + expect(user.abacAttributes).to.be.an('array'); + + const departmentAttr = user?.abacAttributes?.find((attr: IAbacAttributeDefinition) => attr.key === 'department'); + + expect(departmentAttr).to.exist; + expect(departmentAttr?.values || []).to.be.an('array').that.is.not.empty; + }); + + it('should sync ABAC attributes for SOME users via /abac/users/sync', async () => { + // Users already imported from LDAP, but without ABAC attributes. + // We now sync only SOME users, identified by their emails. + const resAlan = await request.get(`${v1}/users.info`).set(credentials).query({ username: 'alan.bean' }).expect(200); + const resBuzz = await request.get(`${v1}/users.info`).set(credentials).query({ username: 'buzz.aldrin' }).expect(200); + + const alanBefore = resAlan.body.user as IUser; + const buzzBefore = resBuzz.body.user as IUser; + + // Ensure they start without ABAC attributes (or with an empty array) + expect(alanBefore).to.have.property('username', 'alan.bean'); + const alanBeforeAttrs = alanBefore.abacAttributes || []; + expect(alanBeforeAttrs).to.be.an('array').that.has.lengthOf(0); + + expect(buzzBefore).to.have.property('username', 'buzz.aldrin'); + const buzzBeforeAttrs = buzzBefore.abacAttributes || []; + expect(buzzBeforeAttrs).to.be.an('array').that.has.lengthOf(0); + + // Sync SOME users by email + await request + .post(`${v1}/abac/users/sync`) + .set(credentials) + .send({ + emails: ['alan.bean@space.air', 'buzz.aldrin@space.air'], + }) + .expect(200); + + const resAlanAfter = await request.get(`${v1}/users.info`).set(credentials).query({ username: 'alan.bean' }).expect(200); + const resBuzzAfter = await request.get(`${v1}/users.info`).set(credentials).query({ username: 'buzz.aldrin' }).expect(200); + + const alanAfter = resAlanAfter.body.user as IUser; + const buzzAfter = resBuzzAfter.body.user as IUser; + + const alanAfterAttrs = alanAfter.abacAttributes || []; + const buzzAfterAttrs = buzzAfter.abacAttributes || []; + + expect(alanAfterAttrs).to.be.an('array').that.is.not.empty; + expect(buzzAfterAttrs).to.be.an('array').that.is.not.empty; + + const alanDept = alanAfterAttrs.find((attr: IAbacAttributeDefinition) => attr.key === 'department'); + const buzzDept = buzzAfterAttrs.find((attr: IAbacAttributeDefinition) => attr.key === 'department'); + + expect(alanDept).to.exist; + expect(alanDept?.values || []).to.be.an('array').that.is.not.empty; + + expect(buzzDept).to.exist; + expect(buzzDept?.values || []).to.be.an('array').that.is.not.empty; + }); + + it('should support /abac/users/sync with usernames as param', async () => { + await request + .post(`${v1}/abac/users/sync`) + .set(credentials) + .send({ + usernames: ['david.scott', 'gene.cernan'], + }) + .expect(200); + + const usersToCheck = ['david.scott', 'gene.cernan']; + + const results = await Promise.all( + usersToCheck.map(async (username) => { + const res = await request.get(`${v1}/users.info`).set(credentials).query({ username }).expect(200); + return res.body.user as IUser; + }), + ); + + for (const user of results) { + const attrs = user.abacAttributes || []; + expect(attrs).to.be.an('array').that.is.not.empty; + + const dept = attrs.find((attr: IAbacAttributeDefinition) => attr.key === 'department'); + expect(dept).to.exist; + expect(dept?.values || []).to.be.an('array').that.is.not.empty; + } + }); + + describe('LDAP ABAC room membership sync', () => { + let roomIdWithAbac: string; + + before(async () => { + // Ensure the ABAC attribute definition for department exists + const attrsRes = await request.get(`${v1}/abac/attributes`).set(credentials).expect(200); + const attrs = attrsRes.body.attributes as IAbacAttributeDefinition[]; + const existingDept = attrs.find((attr) => attr.key === 'department'); + + if (!existingDept) { + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ + key: 'department', + values: ['lifeSupport', 'lifeSupport2', 'navControl'], + }) + .expect(200); + } + + // Create a private room and add LDAP users that will later lose ABAC compliance + const roomRes = await createRoom({ + type: 'p', + name: `ldapAbacRoom-${Date.now()}`, + }); + + roomIdWithAbac = roomRes.body.group._id; + + const davidUser = await request.get(`${v1}/users.info`).set(credentials).query({ username: 'david.scott' }).expect(200); + const sergeiUser = await request.get(`${v1}/users.info`).set(credentials).query({ username: 'sergei.krikalev' }).expect(200); + + await addAbacAttributesToUserDirectly(davidUser.body.user._id, [{ key: 'department', values: ['navControl'] }]); + await addAbacAttributesToUserDirectly(sergeiUser.body.user._id, [{ key: 'department', values: ['navControl'] }]); + await addAbacAttributesToUserDirectly(credentials['X-User-Id'], [{ key: 'department', values: ['navControl'] }]); + + await request + .post(`${v1}/abac/rooms/${roomIdWithAbac}/attributes/department`) + .set(credentials) + .send({ + values: ['navControl'], + }) + .expect(200); + + // Invite two LDAP users that will initially match the room attributes + await request + .post(`${v1}/groups.invite`) + .set(credentials) + .send({ + roomId: roomIdWithAbac, + usernames: ['david.scott', 'sergei.krikalev'], + }); + }); + + after(async () => { + await deleteRoom({ type: 'p', roomId: roomIdWithAbac }); + }); + + it('should remove users from room after LDAP sync changes their ABAC attributes', async () => { + const initialDept = 'navControl'; + + const davidInitialAttrs = [{ key: 'department', values: [initialDept] }]; + const sergeiInitialAttrs = [{ key: 'department', values: [initialDept] }]; + + expect(davidInitialAttrs[0].values).to.include(initialDept); + expect(sergeiInitialAttrs[0].values).to.include(initialDept); + + await request + .post(`${v1}/abac/users/sync`) + .set(credentials) + .send({ + usernames: ['david.scott', 'sergei.krikalev'], + }) + .expect(200); + + const afterMembersRes = await request.get(`${v1}/groups.members`).set(credentials).query({ roomId: roomIdWithAbac }).expect(200); + + const afterMembers = afterMembersRes.body.members as IUser[]; + const afterUsernames = afterMembers.map((u) => u.username); + + expect(afterUsernames).to.not.include('david.scott'); + expect(afterUsernames).to.not.include('sergei.krikalev'); + + const userInfoDavidAfter = await request.get(`${v1}/users.info`).set(credentials).query({ username: 'david.scott' }).expect(200); + const userInfoSergeiAfter = await request + .get(`${v1}/users.info`) + .set(credentials) + .query({ username: 'sergei.krikalev' }) + .expect(200); + + const davidAfter = userInfoDavidAfter.body.user as IUser; + const sergeiAfter = userInfoSergeiAfter.body.user as IUser; + + const davidDeptAfter = (davidAfter.abacAttributes || []).find((attr: IAbacAttributeDefinition) => attr.key === 'department'); + const sergeiDeptAfter = (sergeiAfter.abacAttributes || []).find((attr: IAbacAttributeDefinition) => attr.key === 'department'); + + expect(davidDeptAfter?.values || []).to.not.deep.equal(davidInitialAttrs[0].values); + expect(sergeiDeptAfter?.values || []).to.not.deep.equal(sergeiInitialAttrs[0].values); + }); + }); + + after(async () => { + await Promise.all([ + updateSetting('LDAP_Enable', false), + updateSetting('ABAC_Enabled', false), + updateSetting('LDAP_Background_Sync', false), + updateSetting('LDAP_Background_Sync_Import_New_Users', false), + updateSetting('LDAP_Host', ''), + updateSetting('LDAP_Authentication', false), + updateSetting('LDAP_Authentication_UserDN', ''), + updateSetting('LDAP_Authentication_Password', ''), + updateSetting('LDAP_BaseDN', ''), + updateSetting('LDAP_AD_User_Search_Field', ''), + updateSetting('LDAP_AD_Username_Field', ''), + updateSetting('LDAP_Background_Sync_ABAC_Attributes', false), + updateSetting('LDAP_Background_Sync_ABAC_Attributes_Interval', ''), + updateSetting('LDAP_ABAC_AttributeMap', ''), + ]); + }); + }); }); diff --git a/development/ldap/02-data.ldif b/development/ldap/02-data.ldif new file mode 100644 index 0000000000000..bb5f7f5ee594c --- /dev/null +++ b/development/ldap/02-data.ldif @@ -0,0 +1,204 @@ +version: 1 + +dn: dc=space,dc=air +objectClass: dcObject +objectClass: organization +objectClass: top +dc: space +o: spaceair + +dn: ou=users,dc=space,dc=air +objectClass: organizationalUnit +objectClass: top +ou: users + +dn: ou=others,dc=space,dc=air +objectClass: organizationalUnit +objectClass: top +ou: others + +dn: cn=astronauts,dc=space,dc=air +objectClass: groupOfNames +objectClass: top +cn: astronauts +member: uid=alan.bean,ou=users,dc=space,dc=air +member: uid=buzz.aldrin,ou=users,dc=space,dc=air +member: uid=david.scott,ou=users,dc=space,dc=air +member: uid=gene.cernan,ou=users,dc=space,dc=air +member: uid=john.young,ou=users,dc=space,dc=air + +dn: cn=cosmonauts,dc=space,dc=air +objectClass: groupOfNames +objectClass: top +cn: cosmonauts +member: uid=sergei.krikalev,ou=users,dc=space,dc=air +member: uid=yuri.romanenko,ou=users,dc=space,dc=air + +dn: uid=alan.bean,ou=users,dc=space,dc=air +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +objectClass: top +cn: Alan Bean +sn: Alan +mail: alan.bean@space.air +uid: alan.bean +destinationIndicator: Canada +employeeType: fulltime +departmentNumber: navControl +userPassword:: e1NIQTI1Nn1sMzlna05QRUU1U29aaGhyRHo1VmVqVGpXMVJ6MHNNOTdjSGpsN + 1RaVHZJPQ== + + +dn: uid=john.young,ou=users,dc=space,dc=air +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +objectClass: top +cn: John Young +sn: John +mail: john.young@space.air +uid: john.young +destinationIndicator: Brazil +employeeType: parttime +departmentNumber: flightOps +departmentNumber: flightOps2 +telephoneNumber: +1-555-0102 +telephoneNumber: +1-555-0103 +telephoneNumber: +1-555-0104 +userPassword:: e1NIQTI1Nn1sMzlna05QRUU1U29aaGhyRHo1VmVqVGpXMVJ6MHNNOTdjSGpsN + 1RaVHZJPQ== + + +dn: uid=buzz.aldrin,ou=users,dc=space,dc=air +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +objectClass: top +cn: Buzz Aldrin +sn: Buzz +mail: buzz.aldrin@space.air +uid: buzz.aldrin +destinationIndicator: Japan +employeeType: contractor +departmentNumber: propulsion +userPassword:: e1NIQTI1Nn1sMzlna05QRUU1U29aaGhyRHo1VmVqVGpXMVJ6MHNNOTdjSGpsN + 1RaVHZJPQ== + + +dn: uid=david.scott,ou=users,dc=space,dc=air +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +objectClass: top +cn: David Scott +sn: David +mail: david.scott@space.air +uid: david.scott +destinationIndicator: Germany +employeeType: intern +departmentNumber: lifeSupport +userPassword:: e1NIQTI1Nn1sMzlna05QRUU1U29aaGhyRHo1VmVqVGpXMVJ6MHNNOTdjSGpsN + 1RaVHZJPQ== + + +dn: uid=gene.cernan,ou=users,dc=space,dc=air +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +objectClass: top +cn: Gene Cernan +sn: Gene +mail: gene.cernan@space.air +uid: gene.cernan +destinationIndicator: Kenya +employeeType: consultant +departmentNumber: geoScience +userPassword:: e1NIQTI1Nn1sMzlna05QRUU1U29aaGhyRHo1VmVqVGpXMVJ6MHNNOTdjSGpsN + 1RaVHZJPQ== + + +dn: uid=james.irwin,ou=users,dc=space,dc=air +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +objectClass: top +cn: James Irwin +sn: James +mail: james.irwin@space.air +uid: james.irwin +destinationIndicator: Norway +employeeType: seasonal +departmentNumber: dataSystems +userPassword:: e1NIQTI1Nn1sMzlna05QRUU1U29aaGhyRHo1VmVqVGpXMVJ6MHNNOTdjSGpsN + 1RaVHZJPQ== + + + + + + + + + +dn: uid=neil.armstrong,ou=users,dc=space,dc=air +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +objectClass: top +cn: Neil Armstrong +sn: Neil +mail: neil.armstrong@space.air +uid: neil.armstrong +destinationIndicator: Finland +employeeType: parttime +departmentNumber: flightOps2 +userPassword:: e1NIQTI1Nn1sMzlna05QRUU1U29aaGhyRHo1VmVqVGpXMVJ6MHNNOTdjSGpsN + 1RaVHZJPQ== + + +dn: uid=yuri.romanenko,ou=users,dc=space,dc=air +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +objectClass: top +cn: Yuri Romanenko +sn: Yuri +mail: yuri.romanenko@space.air +uid: yuri.romanenko +destinationIndicator: Mexico +employeeType: contractor +departmentNumber: propulsion2 +userPassword:: e1NIQTI1Nn1sMzlna05QRUU1U29aaGhyRHo1VmVqVGpXMVJ6MHNNOTdjSGpsN + 1RaVHZJPQ== + + +dn: uid=sergei.krikalev,ou=users,dc=space,dc=air +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +objectClass: top +cn: Sergei Krikalev +sn: Sergei +mail: sergei.krikalev@space.air +uid: sergei.krikalev +destinationIndicator: Italy +employeeType: intern +departmentNumber: lifeSupport2 +userPassword:: e1NIQTI1Nn1sMzlna05QRUU1U29aaGhyRHo1VmVqVGpXMVJ6MHNNOTdjSGpsN + 1RaVHZJPQ== + + +dn: uid=userwithnoemail,ou=users,dc=space,dc=air +objectClass: inetOrgPerson +objectClass: organizationalPerson +objectClass: person +objectClass: top +cn: User With No Email +sn: NoEmail +uid: userwithnoemail +destinationIndicator: Portugal +employeeType: consultant +departmentNumber: geoScience2 +userPassword:: e1NIQTI1Nn1sMzlna05QRUU1U29aaGhyRHo1VmVqVGpXMVJ6MHNNOTdjSGpsN + 1RaVHZJPQ== diff --git a/docker-compose-ci.yml b/docker-compose-ci.yml index e5b4eabea8775..e69ad1047a947 100644 --- a/docker-compose-ci.yml +++ b/docker-compose-ci.yml @@ -210,7 +210,7 @@ services: bash -c "mongod --replSet $$MONGODB_REPLICA_SET_NAME --bind_ip_all & sleep 2; - until mongosh --eval \"db.adminCommand('ping')\"; do + until mongosh --eval \"db.adminCommand('ping')\"; do echo '=====> Waiting for Mongo...'; sleep 1; done; @@ -231,3 +231,19 @@ services: - 3000:80 volumes: - /var/run/docker.sock:/var/run/docker.sock + + openldap: + image: bitnamilegacy/openldap:latest + volumes: + - ./development/ldap:/opt/bitnami/openldap/data/ + environment: + - LDAP_ADMIN_USERNAME=admin + - LDAP_ADMIN_PASSWORD=adminpassword + - LDAP_ROOT=dc=space,dc=air + - LDAP_ADMIN_DN=cn=admin,dc=space,dc=air + - LDAP_CUSTOM_LDIF_DIR=/opt/bitnami/openldap/data + - LDAP_LOGLEVEL=-1 + - BITNAMI_DEBUG=false + ports: + - 1389:1389 + - 1636:1636