#!/usr/bin/env python3 # ChangeLog # --------- # 2022-10-29: # LDAP sync script added, thanks to hpvb: # - syncs LDAP teams and avatars to WeKan MongoDB database # - removes or disables WeKan users that are also disabled at LDAP # TODO: # - There is hardcoded value of avatar URL example.com . # Try to change it to use existing environment variables. import os import environs import ldap import hashlib from pymongo import MongoClient from pymongo.errors import DuplicateKeyError env = environs.Env() stats = { 'created': 0, 'updated': 0, 'disabled': 0, 'team_created': 0, 'team_updated': 0, 'team_disabled': 0, 'team_membership_update': 0, 'board_membership_update': 0 } mongodb_client = MongoClient(env('MONGO_URL')) mongodb_database = mongodb_client[env('MONGO_DBNAME')] class LdapConnection: def __init__(self): self.url = env('LDAP_URL') self.binddn = env('LDAP_BINDDN', default='') self.bindpassword = env('LDAP_BINDPASSWORD', default='') self.basedn = env('LDAP_BASEDN') self.group_base = env('LDAP_GROUP_BASE') self.group_name_attribute = env('LDAP_GROUP_NAME_ATTRIBUTE') self.admin_group = env('LDAP_ADMIN_GROUP', default=None) self.user_base = env('LDAP_USER_BASE') self.user_group = env('LDAP_USER_GROUP', default=None) self.user_objectclass = env('LDAP_USER_OBJECTCLASS') self.user_username_attribute = env('LDAP_USER_USERNAME_ATTRIBUTE') self.user_fullname_attribute = env('LDAP_USER_FULLNAME_ATTRIBUTE') self.user_email_attribute = env('LDAP_USER_EMAIL_ATTRIBUTE') self.user_photo_attribute = env('LDAP_USER_PHOTO_ATTRIBUTE', default=None) self.user_attributes = [ "memberOf", "entryUUID", "initials", self.user_username_attribute, self.user_fullname_attribute, self.user_email_attribute ] if self.user_photo_attribute: self.user_attributes.append(self.user_photo_attribute) self.con = ldap.initialize(self.url) self.con.simple_bind_s(self.binddn, self.bindpassword) def get_groups(self): search_base = f"{self.group_base},{self.basedn}" search_filter=f"(objectClass=groupOfNames)" res = self.con.search(search_base, ldap.SCOPE_SUBTREE, search_filter, ['cn', 'description', 'o', 'entryUUID']) result_set = {} while True: result_type, result_data = self.con.result(res, 0) if (result_data == []): break else: if result_type == ldap.RES_SEARCH_ENTRY: ldap_data = {} data = {} for attribute in result_data[0][1]: ldap_data[attribute] = [ val.decode() for val in result_data[0][1][attribute] ] try: data['dn'] = result_data[0][0] data['name'] = ldap_data['cn'][0] data['uuid'] = ldap_data['entryUUID'][0] try: data['description'] = ldap_data['description'][0] except KeyError: data['description'] = data['name'] result_set[data['name']] = data except KeyError as e: print(f"Skipping Ldap object {result_data[0][0]}, missing attribute {e}.") return result_set def get_group_name(self, dn): res = self.con.search(dn, ldap.SCOPE_BASE, None, [self.group_name_attribute]) result_type, result_data = self.con.result(res, 0) if result_type == ldap.RES_SEARCH_ENTRY: return result_data[0][1][self.group_name_attribute][0].decode() def get_users(self): search_base = f"{self.user_base},{self.basedn}" search_filter = "" if self.user_group: search_filter=f"(&(objectClass={self.user_objectclass})(memberof={self.user_group},{self.basedn}))" else: search_filter=f"(objectClass={self.user_objectclass})" ldap_groups = self.get_groups() res = self.con.search(search_base, ldap.SCOPE_SUBTREE, search_filter, self.user_attributes) result_set = {} while True: result_type, result_data = self.con.result(res, 0) if (result_data == []): break else: if result_type == ldap.RES_SEARCH_ENTRY: ldap_data = {} data = {} for attribute in result_data[0][1]: if attribute == self.user_photo_attribute: ldap_data[attribute] = result_data[0][1][attribute] else: ldap_data[attribute] = [ val.decode() for val in result_data[0][1][attribute] ] try: data['dn'] = result_data[0][0] data['username'] = ldap_data[self.user_username_attribute][0] data['full_name'] = ldap_data[self.user_fullname_attribute][0] data['email'] = ldap_data[self.user_email_attribute][0] data['uuid'] = ldap_data['entryUUID'][0] try: data['initials'] = ldap_data['initials'][0] except KeyError: data['initials'] = '' try: data['photo'] = ldap_data[self.user_photo_attribute][0] data['photo_hash'] = hashlib.md5(data['photo']).digest() except KeyError: data['photo'] = None data['is_superuser'] = f"{self.admin_group},{self.basedn}" in ldap_data['memberOf'] data['groups'] = [] for group in ldap_data['memberOf']: if group.endswith(f"{self.group_base},{self.basedn}"): data['groups'].append(ldap_groups[self.get_group_name(group)]) result_set[data['username']] = data except KeyError as e: print(f"Skipping Ldap object {result_data[0][0]}, missing attribute {e}.") return result_set def create_wekan_user(ldap_user): user = { "_id": ldap_user['uuid'], "username": ldap_user['username'], "emails": [ { "address": ldap_user['email'], "verified": True } ], "isAdmin": ldap_user['is_superuser'], "loginDisabled": False, "authenticationMethod": 'oauth2', "sessionData": {}, "importUsernames": [ None ], "teams": [], "orgs": [], "profile": { "fullname": ldap_user['full_name'], "avatarUrl": f"https://example.com/user/profile_picture/{ldap_user['username']}", "initials": ldap_user['initials'], "boardView": "board-view-swimlanes", "listSortBy": "-modifiedAt", }, "services": { "oidc": { "id": ldap_user['username'], "username": ldap_user['username'], "fullname": ldap_user['full_name'], "email": ldap_user['email'], "groups": [], }, }, } try: mongodb_database["users"].insert_one(user) print(f"Creating new Wekan user {ldap_user['username']}") stats['created'] += 1 except DuplicateKeyError: print(f"Wekan user {ldap_user['username']} already exists.") update_wekan_user(ldap_user) def update_wekan_user(ldap_user): updated = False user = mongodb_database["users"].find_one({"username": ldap_user['username']}) if user["emails"][0]["address"] != ldap_user['email']: updated = True user["emails"][0]["address"] = ldap_user['email'] if user["emails"][0]["verified"] != True: updated = True user["emails"][0]["verified"] = True if user["isAdmin"] != ldap_user['is_superuser']: updated = True user["isAdmin"] = ldap_user['is_superuser'] try: if user["loginDisabled"] != False: updated = True user["loginDisabled"] = False except KeyError: updated = True user["loginDisabled"] = False if user["profile"]["fullname"] != ldap_user['full_name']: updated = True user["profile"]["fullname"] = ldap_user['full_name'] if user["profile"]["avatarUrl"] != f"https://example.com/user/profile_picture/{ldap_user['username']}": updated = True user["profile"]["avatarUrl"] = f"https://example.com/user/profile_picture/{ldap_user['username']}" if user["profile"]["initials"] != ldap_user['initials']: updated = True user["profile"]["initials"] = ldap_user['initials'] if user["services"]["oidc"]["fullname"] != ldap_user['full_name']: updated = True user["services"]["oidc"]["fullname"] = ldap_user['full_name'] if user["services"]["oidc"]["email"] != ldap_user['email']: updated = True user["services"]["oidc"]["email"] = ldap_user['email'] if updated: print(f"Updated Wekan user {ldap_user['username']}") stats['updated'] += 1 mongodb_database["users"].update_one({"username": ldap_user['username']}, {"$set": user}) def disable_wekan_user(username): print(f"Disabling Wekan user {username}") stats['disabled'] += 1 mongodb_database["users"].update_one({"username": username}, {"$set": {"loginDisabled": True}}) def create_wekan_team(ldap_group): print(f"Creating new Wekan team {ldap_group['name']}") stats['team_created'] += 1 team = { "_id": ldap_group['uuid'], "teamShortName": ldap_group["name"], "teamDisplayName": ldap_group["name"], "teamDesc": ldap_group["description"], "teamWebsite": "http://localhost", "teamIsActive": True } mongodb_database["team"].insert_one(team) def update_wekan_team(ldap_group): updated = False team = mongodb_database["team"].find_one({"_id": ldap_group['uuid']}) team_tmp = { "_id": ldap_group['uuid'], "teamShortName": ldap_group["name"], "teamDisplayName": ldap_group["name"], "teamDesc": ldap_group["description"], "teamWebsite": "http://localhost", "teamIsActive": True } for key, value in team_tmp.items(): try: if team[key] != value: updated = True break except KeyError: updated = True if updated: print(f"Updated Wekan team {ldap_group['name']}") stats['team_updated'] += 1 mongodb_database["team"].update_one({"_id": ldap_group['uuid']}, {"$set": team_tmp}) def disable_wekan_team(teamname): print(f"Disabling Wekan team {teamname}") stats['team_disabled'] += 1 mongodb_database["team"].update_one({"teamShortName": teamname}, {"$set": {"teamIsActive": False}}) def update_wekan_team_memberships(ldap_user): updated = False user = mongodb_database["users"].find_one({"username": ldap_user['username']}) teams = user["teams"] teams_tmp = [] for group in ldap_user["groups"]: teams_tmp.append({ 'teamId': group['uuid'], 'teamDisplayName': group['name'], }) for team in teams_tmp: if team not in teams: updated = True break if len(teams) != len(teams_tmp): updated = True if updated: print(f"Updated Wekan team memberships for {ldap_user['username']}") stats['team_membership_update'] += 1 mongodb_database["users"].update_one({"username": ldap_user['username']}, {"$set": { "teams" : teams_tmp }}) def update_wekan_board_memberships(ldap_users): for board in mongodb_database["boards"].find(): try: if board['type'] != 'board': continue except KeyError: continue if not "teams" in board.keys(): continue members = [] if "members" in board.keys(): members = board["members"] members_tmp = [] for team in board["teams"]: for username, user in ldap_users.items(): for group in user["groups"]: if group['uuid'] == team['teamId']: user_tmp = { 'userId': user['uuid'], 'isAdmin': user['is_superuser'], 'isActive': True, 'isNoComments': False, 'isCommentOnly': False, 'isWorker': False } if user_tmp not in members_tmp: members_tmp.append(user_tmp.copy()) board_users = [] for card in mongodb_database["cards"].find({"boardId": board['_id']}): if card['userId'] not in board_users: board_users.append(card['userId']) inactive_board_users = board_users.copy() for member in members_tmp: if member['userId'] in board_users: inactive_board_users.remove(member['userId']) for inactive_board_user in inactive_board_users: user_tmp = { 'userId': inactive_board_user, 'isAdmin': False, 'isActive': False, 'isNoComments': False, 'isCommentOnly': False, 'isWorker': False } if user_tmp not in members_tmp: members_tmp.append(user_tmp.copy()) if members != members_tmp: print(f"Updated Wekan board membership for {board['title']}") stats['board_membership_update'] += 1 mongodb_database["boards"].update_one({"_id": board["_id"]}, {"$set": { "members" : members_tmp }}) def ldap_sync(): print("Fetching users from LDAP") ldap = LdapConnection() ldap_users = ldap.get_users() ldap_username_list = ldap_users.keys() print("Fetching users from Wekan") wekan_username_list = [] for user in mongodb_database["users"].find(): if not user['loginDisabled']: wekan_username_list.append(user['username']) print("Sorting users") not_in_ldap = [] not_in_wekan = [] in_wekan = [] for ldap_username in ldap_username_list: if ldap_username in wekan_username_list: in_wekan.append(ldap_username) else: not_in_wekan.append(ldap_username) for wekan_username in wekan_username_list: if wekan_username not in ldap_username_list: not_in_ldap.append(wekan_username) print("Fetching groups from LDAP") ldap_groups = ldap.get_groups() ldap_groupname_list = ldap_groups.keys() print("Fetching teams from Wekan") wekan_teamname_list = [] for team in mongodb_database["team"].find(): if team['teamIsActive']: wekan_teamname_list.append(team['teamShortName']) print("Sorting groups") group_not_in_ldap = [] group_not_in_wekan = [] group_in_wekan = [] for ldap_groupname in ldap_groupname_list: if ldap_groupname in wekan_teamname_list: group_in_wekan.append(ldap_groupname) else: group_not_in_wekan.append(ldap_groupname) for wekan_teamname in wekan_teamname_list: if wekan_teamname not in ldap_groupname_list: group_not_in_ldap.append(wekan_teamname) print("Processing users") for user in not_in_wekan: create_wekan_user(ldap_users[user]) for user in in_wekan: update_wekan_user(ldap_users[user]) for user in not_in_ldap: disable_wekan_user(user) print("Processing groups") for group in group_not_in_wekan: create_wekan_team(ldap_groups[group]) for group in group_in_wekan: update_wekan_team(ldap_groups[group]) for team in group_not_in_ldap: disable_wekan_team(team) for username, user in ldap_users.items(): update_wekan_team_memberships(user) print("Updating board memberships") update_wekan_board_memberships(ldap_users) print() print(f"Total users considered: {len(ldap_username_list)}") print(f"Total groups considered: {len(ldap_groups)}") print(f"Users created {stats['created']}") print(f"Users updated {stats['updated']}") print(f"Users disabled {stats['disabled']}") print(f"Teams created {stats['team_created']}") print(f"Teams updated {stats['team_updated']}") print(f"Teams disabled {stats['team_disabled']}") print(f"Team memberships updated: {stats['team_membership_update']}") print(f"Board memberships updated: {stats['board_membership_update']}") if __name__ == "__main__": ldap_sync()