Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions ctldap-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]));
Expand Down
3 changes: 2 additions & 1 deletion ctldap-site.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
83 changes: 73 additions & 10 deletions ctldap.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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) => {
Expand All @@ -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 };
});
}
Expand All @@ -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: {
Expand All @@ -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
}
};
});
Expand Down Expand Up @@ -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)
}
Expand Down
16 changes: 15 additions & 1 deletion ctldap.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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
Expand All @@ -69,4 +81,6 @@ sites:
# specialGroupMappings:
# nextcloud:
# groupClass: NextCloudGroup
# personClass: NextCloudUser
# personClass: NextCloudUser
# specialUserAttributes:
# age: '$.age'
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down