|
@@ -0,0 +1,438 @@
|
|
|
|
+#!/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())
|
|
|
|
+
|
|
|
|
+ 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()
|