diff --git a/src/externalldap.js b/src/externalldap.js index 37cc3aaeb..52bf17c23 100644 --- a/src/externalldap.js +++ b/src/externalldap.js @@ -219,6 +219,18 @@ function testConfig(config, callback) { if (!config.filter) return callback(new BoxError(BoxError.BAD_FIELD, 'filter must not be empty')); try { ldap.parseFilter(config.filter); } catch (e) { return callback(new BoxError(BoxError.BAD_FIELD, 'invalid filter')); } + if ('syncGroups' in config && typeof config.syncGroups !== 'boolean') return callback(new BoxError(BoxError.BAD_FIELD, 'syncGroups must be a boolean')); + + if (config.syncGroups) { + if (!config.groupBaseDn) return callback(new BoxError(BoxError.BAD_FIELD, 'groupBaseDn must not be empty')); + try { ldap.parseDN(config.groupBaseDn); } catch (e) { return callback(new BoxError(BoxError.BAD_FIELD, 'invalid groupBaseDn')); } + + if (!config.groupFilter) return callback(new BoxError(BoxError.BAD_FIELD, 'groupFilter must not be empty')); + try { ldap.parseFilter(config.groupFilter); } catch (e) { return callback(new BoxError(BoxError.BAD_FIELD, 'invalid groupFilter')); } + + if (!config.groupnameField || typeof config.groupnameField !== 'string') return callback(new BoxError(BoxError.BAD_FIELD, 'groupFilter must not be empty')); + } + getClient(config, function (error, client) { if (error) return callback(error); @@ -395,7 +407,7 @@ function syncGroups(externalLdapConfig, progressCallback, callback) { if (!externalLdapConfig.syncGroups) { debug('Group sync is disabled'); - progressCallback({ percent: 70, message: `Skipping group sync...` }); + progressCallback({ percent: 70, message: 'Skipping group sync...' }); return callback(null, []); } @@ -409,8 +421,10 @@ function syncGroups(externalLdapConfig, progressCallback, callback) { // we ignore all non internal errors here and just log them for now async.eachSeries(ldapGroups, function (ldapGroup, iteratorCallback) { - var groupName = ldapGroup[externalLdapConfig.groupnameField || 'cn']; + var groupName = ldapGroup[externalLdapConfig.groupnameField]; if (!groupName) return iteratorCallback(); + // some servers return empty array for unknown properties :-/ + if (typeof groupName !== 'string') return iteratorCallback(); // groups are lowercase groupName = groupName.toLowerCase(); @@ -462,16 +476,27 @@ function syncGroupUsers(externalLdapConfig, progressCallback, callback) { debug(`Found ${ldapGroups.length} groups to sync users`); async.eachSeries(ldapGroups, function (group, iteratorCallback) { - debug(`Synd users for group ${group.name}`); + debug(`Sync users for group ${group.name}`); - ldapGroupSearch(externalLdapConfig, { filter: `${externalLdapConfig.groupnameField}=${group.name}` }, function (error, result) { + ldapGroupSearch(externalLdapConfig, {}, function (error, result) { if (error) return callback(error); if (!result || result.length === 0) { console.error(`Unable to find group ${group.name} ignoring for now.`); return callback(); } - var ldapGroupMembers = result[0].member; + // since our group names are lowercase we cannot use potentially case matching ldap filters + let found = result.find(function (r) { + if (!r[externalLdapConfig.groupnameField]) return false; + return r[externalLdapConfig.groupnameField].toLowerCase() === group.name; + }); + + if (!found) { + console.error(`Unable to find group ${group.name} ignoring for now.`); + return callback(); + } + + var ldapGroupMembers = found.member || []; debug(`Group ${group.name} has ${ldapGroupMembers.length} members.`); diff --git a/src/test/externalldap-test.js b/src/test/externalldap-test.js index a482d331f..6f3872406 100644 --- a/src/test/externalldap-test.js +++ b/src/test/externalldap-test.js @@ -12,6 +12,7 @@ var async = require('async'), expect = require('expect.js'), externalldap = require('../externalldap.js'), groupdb = require('../groupdb.js'), + groups = require('../groups.js'), domains = require('../domains.js'), ldap = require('ldapjs'), mailboxdb = require('../mailboxdb.js'), @@ -45,12 +46,17 @@ const DOMAIN_0 = { const LDAP_SHARED_PASSWORD = 'validpassword'; const LDAP_PORT = 4321; const LDAP_BASE_DN = 'ou=Users,dc=cloudron,dc=io'; +const LDAP_GROUP_BASE_DN = 'ou=Groups,dc=cloudron,dc=io'; const LDAP_CONFIG = { provider: 'testserver', url: `ldap://localhost:${LDAP_PORT}`, usernameField: 'customusernameprop', baseDn: LDAP_BASE_DN, filter: '(objectClass=inetOrgPerson)', + syncGroups: false, + groupBaseDn: LDAP_GROUP_BASE_DN, + groupFilter: '(objectClass=groupOfNames)', + groupnameField: 'customgroupnameprop', autoCreate: false }; @@ -134,11 +140,12 @@ function finalSend(results, req, res, next) { } let gLdapUsers = []; +let gLdapGroups = []; function startLdapServer(callback) { gLdapServer = ldap.createServer(); - gLdapServer.search(LDAP_CONFIG.baseDn, function (req, res, next) { + gLdapServer.search(LDAP_BASE_DN, function (req, res, next) { let results = []; gLdapUsers.forEach(function (entry) { @@ -163,7 +170,32 @@ function startLdapServer(callback) { finalSend(results, req, res, next); }); - gLdapServer.bind(LDAP_CONFIG.baseDn, function (req, res, next) { + gLdapServer.search(LDAP_GROUP_BASE_DN, function (req, res, next) { + let results = []; + + gLdapGroups.forEach(function (entry) { + var dn = ldap.parseDN(`cn=${entry.groupname},${LDAP_GROUP_BASE_DN}`); + + var obj = { + dn: dn.toString(), + attributes: { + objectclass: [ 'groupOfNames' ], + cn: entry.groupname, + member: entry.member || [] + } + }; + + obj.attributes[LDAP_CONFIG.groupnameField] = entry.groupname; + + if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && req.filter.matches(obj.attributes)) { + results.push(obj); + } + }); + + finalSend(results, req, res, next); + }); + + gLdapServer.bind(LDAP_BASE_DN, function (req, res, next) { // extract the common name which might have different attribute names var attributeName = Object.keys(req.dn.rdns[0].attrs)[0]; var commonName = req.dn.rdns[0].attrs[attributeName].value; @@ -308,6 +340,96 @@ describe('External LDAP', function () { done(); }); }); + + // now test with groups + it('enabling with groups fails with missing groupBaseDn', function (done) { + let conf = _.extend({}, LDAP_CONFIG); + conf.syncGroups = true; + delete conf.groupBaseDn; + + enable(conf, function (error) { + expect(error).to.be.ok(); + expect(error.reason).to.equal(BoxError.BAD_FIELD); + done(); + }); + }); + + it('enabling with groups fails with empty groupBaseDn', function (done) { + let conf = _.extend({}, LDAP_CONFIG); + conf.syncGroups = true; + conf.groupBaseDn = ''; + + enable(conf, function (error) { + expect(error).to.be.ok(); + expect(error.reason).to.equal(BoxError.BAD_FIELD); + done(); + }); + }); + + it('enabling with groups fails with missing groupFilter', function (done) { + let conf = _.extend({}, LDAP_CONFIG); + conf.syncGroups = true; + delete conf.groupFilter; + + enable(conf, function (error) { + expect(error).to.be.ok(); + expect(error.reason).to.equal(BoxError.BAD_FIELD); + done(); + }); + }); + + it('enabling with groups fails with empty groupFilter', function (done) { + let conf = _.extend({}, LDAP_CONFIG); + conf.syncGroups = true; + conf.groupFilter = ''; + + enable(conf, function (error) { + expect(error).to.be.ok(); + expect(error.reason).to.equal(BoxError.BAD_FIELD); + done(); + }); + }); + + it('enabling with groups fails with missing groupnameField', function (done) { + let conf = _.extend({}, LDAP_CONFIG); + conf.syncGroups = true; + delete conf.groupnameField; + + enable(conf, function (error) { + expect(error).to.be.ok(); + expect(error.reason).to.equal(BoxError.BAD_FIELD); + done(); + }); + }); + + it('enabling with groups fails with empty groupnameField', function (done) { + let conf = _.extend({}, LDAP_CONFIG); + conf.syncGroups = true; + conf.groupnameField = ''; + + enable(conf, function (error) { + expect(error).to.be.ok(); + expect(error.reason).to.equal(BoxError.BAD_FIELD); + done(); + }); + }); + + it('enabling with groups succeeds', function (done) { + let conf = _.extend({}, LDAP_CONFIG); + conf.syncGroups = true; + + enable(function (error) { + expect(error).to.equal(null); + done(); + }); + }); + + it('disabling succeeds', function (done) { + disable(function (error) { + expect(error).to.equal(null); + done(); + }); + }); }); describe('sync', function () { @@ -385,6 +507,148 @@ describe('External LDAP', function () { }); }); + it('does not sync group if group sync is disabled', function (done) { + gLdapGroups.push({ + groupname: 'extGroup1' + }); + + externalldap.sync(function progress() {}, function (error) { + expect(error).to.equal(null); + + groups.getAll(function (error, result) { + expect(error).to.equal(null); + expect(result.length).to.equal(0); + + done(); + }); + }); + }); + + it('enable with groupSync', function (done) { + disable(function (error) { + expect(error).to.equal(null); + + let conf = _.extend({}, LDAP_CONFIG); + conf.syncGroups = true; + + enable(conf, done); + }); + }); + + it('succeeds with groups enabled', function (done) { + gLdapGroups = []; + + externalldap.sync(function progress() {}, function (error) { + expect(error).to.equal(null); + + groups.getAll(function (error, result) { + expect(error).to.equal(null); + expect(result.length).to.equal(0); + + done(); + }); + }); + }); + + it('succeeds with groups enabled and new group', function (done) { + gLdapGroups.push({ + groupname: 'extGroup1' + }); + + externalldap.sync(function progress() {}, function (error) { + expect(error).to.equal(null); + + groups.getAll(function (error, result) { + expect(error).to.equal(null); + expect(result.length).to.equal(1); + + done(); + }); + }); + }); + + it('succeeds with groups enabled and second new group', function (done) { + gLdapGroups.push({ + groupname: 'extGroup2' + }); + + externalldap.sync(function progress() {}, function (error) { + expect(error).to.equal(null); + + groups.getAll(function (error, result) { + expect(error).to.equal(null); + expect(result.length).to.equal(2); + + done(); + }); + }); + }); + + it('does not create already existing group', function (done) { + gLdapGroups.push({ + groupname: 'INTERNALgroup' // also tests lowercasing + }); + + groups.create('internalgroup', '', function (error) { + expect(error).to.equal(null); + + externalldap.sync(function progress() {}, function (error) { + expect(error).to.equal(null); + + groups.getAll(function (error, result) { + expect(error).to.equal(null); + expect(result.length).to.equal(3); + + done(); + }); + }); + }); + }); + + it('adds users of groups', function (done) { + gLdapGroups.push({ + groupname: 'nonEmptyGroup', + member: gLdapUsers.slice(-2).map(function (u) { return `cn=${u.username},${LDAP_CONFIG.baseDn}`; }) + }); + + externalldap.sync(function progress() {}, function (error) { + expect(error).to.equal(null); + + groups.getByName('nonemptygroup', function (error, result) { + expect(error).to.equal(null); + + groups.getMembers(result.id, function (error, result) { + expect(error).to.equal(null); + expect(result.length).to.equal(2); + + done(); + }); + }); + }); + }); + + it('adds new users of groups', function (done) { + gLdapGroups.push({ + groupname: 'nonEmptyGroup', + member: gLdapUsers.map(function (u) { return `cn=${u.username},${LDAP_CONFIG.baseDn}`; }) + }); + + externalldap.sync(function progress() {}, function (error) { + expect(error).to.equal(null); + + groups.getByName('nonemptygroup', function (error, result) { + expect(error).to.equal(null); + + groups.getMembers(result.id, function (error, result) { + expect(error).to.equal(null); + expect(result.length).to.equal(4); + + done(); + }); + }); + }); + }); + it('disable', disable); });