diff --git a/ctldap-config.js b/ctldap-config.js index 4fd12ff..5212a7e 100644 --- a/ctldap-config.js +++ b/ctldap-config.js @@ -28,23 +28,28 @@ export class CtldapConfig { this.ldapPassword = config.ldapPassword; this.ctUri = config.ctUri; this.apiToken = config.apiToken; - this.specialGroupMappings = config.specialGroupMappings || {}; + this.specialGroupMappings = config.specialGroupMappings; + this.specialUserAttributes = config.specialUserAttributes; this.dnLowerCase = CtldapConfig.asOptionalBool(config.dnLowerCase); this.emailLowerCase = CtldapConfig.asOptionalBool(config.emailLowerCase); this.emailsUnique = CtldapConfig.asOptionalBool(config.emailsUnique); + this.filterInvitedPersons = CtldapConfig.asOptionalBool(config.filterInvitedPersons); + this.virtualRoleGroups = CtldapConfig.asOptionalBool(config.virtualRoleGroups); + this.skipEmptyGroups = CtldapConfig.asOptionalBool(config.skipEmptyGroups); this.ldapCertFilename = config.ldapCertFilename; this.ldapKeyFilename = config.ldapKeyFilename; this.ldapBaseDn = config.ldapBaseDn; // Configure sites const sites = yaml.sites || {}; // If ldapBaseDn is set, create a site from the global config properties. - if (config.ldapBaseDn) { - sites[config.ldapBaseDn] = { - ldapUser: config.ldapUser, - ldapPassword: config.ldapPassword, - ctUri: config.ctUri, - apiToken: config.apiToken, - specialGroupMappings: config.specialGroupMappings + if (this.ldapBaseDn) { + sites[this.ldapBaseDn] = { + ldapUser: this.ldapUser, + ldapPassword: this.ldapPassword, + ctUri: this.ctUri, + apiToken: this.apiToken, + specialGroupMappings: this.specialGroupMappings, + specialUserAttributes: this.specialUserAttributes } } this.sites = Object.keys(sites).map((siteName) => new CtldapSite(this, siteName, sites[siteName])); diff --git a/ctldap-site.js b/ctldap-site.js index c696ed3..c61e33d 100644 --- a/ctldap-site.js +++ b/ctldap-site.js @@ -23,7 +23,8 @@ export class CtldapSite { // Take ldapUser from main config if not specified for site. this.ldapUser = site.ldapUser || config.ldapUser; this.ldapPassword = site.ldapPassword; - this.specialGroupMappings = site.specialGroupMappings; + this.specialGroupMappings = site.specialGroupMappings || {}; + this.specialUserAttributes = site.specialUserAttributes || {}; this.dnLowerCase = CtldapConfig.asOptionalBool(site.dnLowerCase); this.emailLowerCase = CtldapConfig.asOptionalBool(site.emailLowerCase); this.emailsUnique = CtldapConfig.asOptionalBool(site.emailsUnique); diff --git a/ctldap.js b/ctldap.js index daa7db6..f885f6c 100644 --- a/ctldap.js +++ b/ctldap.js @@ -7,6 +7,7 @@ * @licence GNU/GPL v3.0 */ import fs from "fs"; +import jp from "jsonpath"; import ldapjs from "ldapjs"; import { CtldapConfig } from "./ctldap-config.js"; import { patchLdapjsFilters } from "./ldapjs-filter-overrides.js"; @@ -163,8 +164,13 @@ async function fetchMemberships(site) { const result = await site.api.get('groups/members', { searchParams: {"with_deleted": false} }); - logDebug(site, "fetchMemberships done"); - return result['data']; + logDebug(site, "fetchMemberships done, found " + result['data'].length + " memberships"); + const memberships = []; + result['data'].forEach((m) => { + m.groupId = 'g' + m.groupId + memberships.push(m); + }); + return memberships; } /** @@ -174,12 +180,20 @@ async function fetchMemberships(site) { */ async function fetchPersons(site) { const data = await fetchAllPaginated(site, 'persons', { limit: 500 }); - logDebug(site, "fetchPersons done"); + logDebug(site, "fetchPersons done, found " + data.length + " persons"); const personMap = {}; data.forEach((p) => { - if (p['invitationStatus'] === "accepted") { - personMap[p['id']] = p; + const filterInvitedPersons = (site.filterInvitedPersons || ((site.filterInvitedPersons === undefined) && config.filterInvitedPersons)); + if ((!filterInvitedPersons) || (filterInvitedPersons && p['invitationStatus'] === "accepted")) { + if (p['cmsUserId'] === ''){ + p.cmsUserId = (p['firstName'] + '.' + p['lastName']).toLowerCase() + .replace('ö', 'oe') + .replace('ä', 'ae') + .replace('ü', 'ue') + .replace('ß', 'ss'); + } p.dn = site.compatTransform(site.fnUserDn(p['cmsUserId'])); + personMap[p['id']] = p; } }); return personMap; @@ -191,15 +205,30 @@ async function fetchPersons(site) { */ async function fetchGroups(site) { const data = await fetchAllPaginated(site, 'groups', { limit: 100 }); - logDebug(site, "fetchGroups done"); + logDebug(site, "fetchGroups done, found " + data.length + " groups"); const groupMap = {}; const sgmKeys = Object.keys(site.specialGroupMappings); data.forEach((g) => { + + if (site.virtualRoleGroups || ((site.virtualRoleGroups === undefined) && config.virtualRoleGroups)) { + g['roles'].forEach((r) => { + // Create new Group for each role + const s = {}; + s.dn = site.compatTransform(site.fnGroupDn(g['name'] + ' ' + r['name'])); + s.id = 'r' + r.id; + s.name = g['name'] + ' ' + r['name']; + s.information = g.information; + const info = s['information']; + s.specialClasses = sgmKeys.filter((k) => info[k]) + groupMap[s['id']] = s; + }); + } + // Strip some irrelevant information delete g['settings']; - delete g['roles']; // Pre-compute the "distinguished name" of this group for LDAP g.dn = site.compatTransform(site.fnGroupDn(g['name'])); + g.id = 'g' + g.id; const info = g['information']; g.specialClasses = sgmKeys.filter((k) => info[k]) groupMap[g['id']] = g; @@ -229,6 +258,24 @@ async function fetchAll(site) { const [personMap, groupMap, memberships, groupTypes] = await Promise.all([ fetchPersons(site), fetchGroups(site), fetchMemberships(site), fetchGroupTypes(site) ]); + + if (site.virtualRoleGroups || ((site.virtualRoleGroups === undefined) && config.virtualRoleGroups)) { + memberships.forEach((m) => { + const n = structuredClone(m); + + Object.entries(groupMap).forEach(([key, g]) => { + if (m.groupId == g.id) { + g.roles.forEach((r) => { + if (m.groupTypeRoleId == r.groupTypeRoleId) { + n.groupId = 'r' + r.id + memberships.push(n) + } + }); + } + }) + }); + } + // Create membership mappings const g2p = {}, p2g = {}; memberships.forEach((m) => { @@ -249,6 +296,14 @@ async function fetchAll(site) { } } }); + if (site.skipEmptyGroups || ((site.skipEmptyGroups === undefined) && config.skipEmptyGroups)) { + Object.entries(groupMap).forEach(([key, g]) => { + if (!(key in g2p)) { + logDebug(site, "Removed empty group: " + g.dn); + delete groupMap[key] + } + }); + } return { groupTypes, g2p, p2g, personMap, groupMap }; }); } @@ -266,6 +321,13 @@ function requestUsers(req, _res, next) { let newCache = Object.entries(personMap).map(([id, p]) => { const cn = p['cmsUserId']; const email = site.compatTransformEmail(p['email']); + const extraattributes = {}; + Object.entries(site.specialUserAttributes).forEach(([key, value]) => { + const uvalue = jp.query(p, value); + if (uvalue.length !== 0 && uvalue[0] !== null) { + extraattributes[key] = uvalue; + } + }); return { dn: p.dn, attributes: { @@ -291,7 +353,8 @@ function requestUsers(req, _res, next) { .flatMap((gid) => groupMap[gid].specialClasses) .map((key) => site.specialGroupMappings[key]['personClass']) ], - memberOf: (p2g[id] || []).map((gid) => groupMap[gid].dn) + memberOf: (p2g[id] || []).map((gid) => groupMap[gid].dn), + ...extraattributes } }; }); @@ -340,8 +403,8 @@ function requestGroups(req, _res, next) { attributes: { cn, displayname: g['name'], - id, - nsUniqueId: `g${id}`, + id: id.replace(/^g/g, ''), + nsUniqueId: id, objectClass: objectClasses, uniqueMember: (g2p[id] || []).map((pid) => personMap[pid].dn) } diff --git a/ctldap.yml b/ctldap.yml index 3bfadac..ef2ea41 100644 --- a/ctldap.yml +++ b/ctldap.yml @@ -14,6 +14,12 @@ config: dnLowerCase: ${IS_DN_LOWER_CASE:true} # This is required for clients that need lowercase email addresses, e.g. Seafile emailLowerCase: ${IS_EMAIL_LOWER_CASE:true} + # Hide persons with an invitation status not accepted + filterInvitedPersons: ${IS_FILTER_INVITED_PERSONS:true} + # Create additional virtual groups for each role within a group. For example, for the group 'Event A' with the roles 'Head' and 'Participant', create the additional groups 'Event A Head' and 'Event A Participant'. + virtualRoleGroups: ${IS_VIRTUAL_ROLE_GROUPS:false} + # Hide groups without any member. + skipEmptyGroups: ${IS_SKIP_EMPTY_GROUPS:false} # LDAP admin user, can be a "virtual" root user or a ChurchTools username (virtual root is recommended!) ldapUser: ${LDAP_USER:root} @@ -43,6 +49,12 @@ config: groupClass: NextCloudGroup personClass: NextCloudUser + # This map can be used to add special attributes to the user. They can also get dynamicly from the user + # by jsonpath (https://github.com/dchester/jsonpath#jsonpath-syntax) to the array returned by the API. + # To get an example you can access https://mysite.church.tools/api/persons?limit=5 + #specialUserAttributes: + # age: '$.age' + # To use SSL/TLS, provide file names for x509 certificate and key here # Use this command to create a private key and a certificate: # openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 @@ -69,4 +81,6 @@ sites: # specialGroupMappings: # nextcloud: # groupClass: NextCloudGroup -# personClass: NextCloudUser \ No newline at end of file +# personClass: NextCloudUser +# specialUserAttributes: +# age: '$.age' diff --git a/package.json b/package.json index 25959b3..64ababd 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@node-rs/argon2": "^1.5.2", "@node-rs/bcrypt": "^1.7.3", "got": "^13.0.0", + "jsonpath": "^1.3.0", "ldap-escape": "^2.0.6", "ldapjs": "^3.0.5", "tough-cookie": "^4.1.3",