ldap-sync.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  1. #!/usr/bin/env python3
  2. # ChangeLog
  3. # ---------
  4. # 2022-10-29:
  5. # LDAP sync script added, thanks to hpvb:
  6. # - syncs LDAP teams and avatars to WeKan MongoDB database
  7. # - removes or disables WeKan users that are also disabled at LDAP
  8. # TODO:
  9. # - There is hardcoded value of avatar URL example.com .
  10. # Try to change it to use existing environment variables.
  11. import os
  12. import environs
  13. import ldap
  14. import hashlib
  15. from pymongo import MongoClient
  16. from pymongo.errors import DuplicateKeyError
  17. env = environs.Env()
  18. stats = {
  19. 'created': 0,
  20. 'updated': 0,
  21. 'disabled': 0,
  22. 'team_created': 0,
  23. 'team_updated': 0,
  24. 'team_disabled': 0,
  25. 'team_membership_update': 0,
  26. 'board_membership_update': 0
  27. }
  28. mongodb_client = MongoClient(env('MONGO_URL'))
  29. mongodb_database = mongodb_client[env('MONGO_DBNAME')]
  30. class LdapConnection:
  31. def __init__(self):
  32. self.url = env('LDAP_URL')
  33. self.binddn = env('LDAP_BINDDN', default='')
  34. self.bindpassword = env('LDAP_BINDPASSWORD', default='')
  35. self.basedn = env('LDAP_BASEDN')
  36. self.group_base = env('LDAP_GROUP_BASE')
  37. self.group_name_attribute = env('LDAP_GROUP_NAME_ATTRIBUTE')
  38. self.admin_group = env('LDAP_ADMIN_GROUP', default=None)
  39. self.user_base = env('LDAP_USER_BASE')
  40. self.user_group = env('LDAP_USER_GROUP', default=None)
  41. self.user_objectclass = env('LDAP_USER_OBJECTCLASS')
  42. self.user_username_attribute = env('LDAP_USER_USERNAME_ATTRIBUTE')
  43. self.user_fullname_attribute = env('LDAP_USER_FULLNAME_ATTRIBUTE')
  44. self.user_email_attribute = env('LDAP_USER_EMAIL_ATTRIBUTE')
  45. self.user_photo_attribute = env('LDAP_USER_PHOTO_ATTRIBUTE', default=None)
  46. self.user_attributes = [ "memberOf", "entryUUID", "initials", self.user_username_attribute, self.user_fullname_attribute, self.user_email_attribute ]
  47. if self.user_photo_attribute:
  48. self.user_attributes.append(self.user_photo_attribute)
  49. self.con = ldap.initialize(self.url)
  50. self.con.simple_bind_s(self.binddn, self.bindpassword)
  51. def get_groups(self):
  52. search_base = f"{self.group_base},{self.basedn}"
  53. search_filter=f"(objectClass=groupOfNames)"
  54. res = self.con.search(search_base, ldap.SCOPE_SUBTREE, search_filter, ['cn', 'description', 'o', 'entryUUID'])
  55. result_set = {}
  56. while True:
  57. result_type, result_data = self.con.result(res, 0)
  58. if (result_data == []):
  59. break
  60. else:
  61. if result_type == ldap.RES_SEARCH_ENTRY:
  62. ldap_data = {}
  63. data = {}
  64. for attribute in result_data[0][1]:
  65. ldap_data[attribute] = [ val.decode() for val in result_data[0][1][attribute] ]
  66. try:
  67. data['dn'] = result_data[0][0]
  68. data['name'] = ldap_data['cn'][0]
  69. data['uuid'] = ldap_data['entryUUID'][0]
  70. try:
  71. data['description'] = ldap_data['description'][0]
  72. except KeyError:
  73. data['description'] = data['name']
  74. result_set[data['name']] = data
  75. except KeyError as e:
  76. print(f"Skipping Ldap object {result_data[0][0]}, missing attribute {e}.")
  77. return result_set
  78. def get_group_name(self, dn):
  79. res = self.con.search(dn, ldap.SCOPE_BASE, None, [self.group_name_attribute])
  80. result_type, result_data = self.con.result(res, 0)
  81. if result_type == ldap.RES_SEARCH_ENTRY:
  82. return result_data[0][1][self.group_name_attribute][0].decode()
  83. def get_users(self):
  84. search_base = f"{self.user_base},{self.basedn}"
  85. search_filter = ""
  86. if self.user_group:
  87. search_filter=f"(&(objectClass={self.user_objectclass})(memberof={self.user_group},{self.basedn}))"
  88. else:
  89. search_filter=f"(objectClass={self.user_objectclass})"
  90. ldap_groups = self.get_groups()
  91. res = self.con.search(search_base, ldap.SCOPE_SUBTREE, search_filter, self.user_attributes)
  92. result_set = {}
  93. while True:
  94. result_type, result_data = self.con.result(res, 0)
  95. if (result_data == []):
  96. break
  97. else:
  98. if result_type == ldap.RES_SEARCH_ENTRY:
  99. ldap_data = {}
  100. data = {}
  101. for attribute in result_data[0][1]:
  102. if attribute == self.user_photo_attribute:
  103. ldap_data[attribute] = result_data[0][1][attribute]
  104. else:
  105. ldap_data[attribute] = [ val.decode() for val in result_data[0][1][attribute] ]
  106. try:
  107. data['dn'] = result_data[0][0]
  108. data['username'] = ldap_data[self.user_username_attribute][0]
  109. data['full_name'] = ldap_data[self.user_fullname_attribute][0]
  110. data['email'] = ldap_data[self.user_email_attribute][0]
  111. data['uuid'] = ldap_data['entryUUID'][0]
  112. try:
  113. data['initials'] = ldap_data['initials'][0]
  114. except KeyError:
  115. data['initials'] = ''
  116. try:
  117. data['photo'] = ldap_data[self.user_photo_attribute][0]
  118. data['photo_hash'] = hashlib.md5(data['photo']).digest()
  119. except KeyError:
  120. data['photo'] = None
  121. data['is_superuser'] = f"{self.admin_group},{self.basedn}" in ldap_data['memberOf']
  122. data['groups'] = []
  123. for group in ldap_data['memberOf']:
  124. if group.endswith(f"{self.group_base},{self.basedn}"):
  125. data['groups'].append(ldap_groups[self.get_group_name(group)])
  126. result_set[data['username']] = data
  127. except KeyError as e:
  128. print(f"Skipping Ldap object {result_data[0][0]}, missing attribute {e}.")
  129. return result_set
  130. def create_wekan_user(ldap_user):
  131. user = { "_id": ldap_user['uuid'],
  132. "username": ldap_user['username'],
  133. "emails": [ { "address": ldap_user['email'], "verified": True } ],
  134. "isAdmin": ldap_user['is_superuser'],
  135. "loginDisabled": False,
  136. "authenticationMethod": 'oauth2',
  137. "sessionData": {},
  138. "importUsernames": [ None ],
  139. "teams": [],
  140. "orgs": [],
  141. "profile": {
  142. "fullname": ldap_user['full_name'],
  143. "avatarUrl": f"https://example.com/user/profile_picture/{ldap_user['username']}",
  144. "initials": ldap_user['initials'],
  145. "boardView": "board-view-swimlanes",
  146. "listSortBy": "-modifiedAt",
  147. },
  148. "services": {
  149. "oidc": {
  150. "id": ldap_user['username'],
  151. "username": ldap_user['username'],
  152. "fullname": ldap_user['full_name'],
  153. "email": ldap_user['email'],
  154. "groups": [],
  155. },
  156. },
  157. }
  158. try:
  159. mongodb_database["users"].insert_one(user)
  160. print(f"Creating new Wekan user {ldap_user['username']}")
  161. stats['created'] += 1
  162. except DuplicateKeyError:
  163. print(f"Wekan user {ldap_user['username']} already exists.")
  164. update_wekan_user(ldap_user)
  165. def update_wekan_user(ldap_user):
  166. updated = False
  167. user = mongodb_database["users"].find_one({"username": ldap_user['username']})
  168. if user["emails"][0]["address"] != ldap_user['email']:
  169. updated = True
  170. user["emails"][0]["address"] = ldap_user['email']
  171. if user["emails"][0]["verified"] != True:
  172. updated = True
  173. user["emails"][0]["verified"] = True
  174. if user["isAdmin"] != ldap_user['is_superuser']:
  175. updated = True
  176. user["isAdmin"] = ldap_user['is_superuser']
  177. try:
  178. if user["loginDisabled"] != False:
  179. updated = True
  180. user["loginDisabled"] = False
  181. except KeyError:
  182. updated = True
  183. user["loginDisabled"] = False
  184. if user["profile"]["fullname"] != ldap_user['full_name']:
  185. updated = True
  186. user["profile"]["fullname"] = ldap_user['full_name']
  187. if user["profile"]["avatarUrl"] != f"https://example.com/user/profile_picture/{ldap_user['username']}":
  188. updated = True
  189. user["profile"]["avatarUrl"] = f"https://example.com/user/profile_picture/{ldap_user['username']}"
  190. if user["profile"]["initials"] != ldap_user['initials']:
  191. updated = True
  192. user["profile"]["initials"] = ldap_user['initials']
  193. if user["services"]["oidc"]["fullname"] != ldap_user['full_name']:
  194. updated = True
  195. user["services"]["oidc"]["fullname"] = ldap_user['full_name']
  196. if user["services"]["oidc"]["email"] != ldap_user['email']:
  197. updated = True
  198. user["services"]["oidc"]["email"] = ldap_user['email']
  199. if updated:
  200. print(f"Updated Wekan user {ldap_user['username']}")
  201. stats['updated'] += 1
  202. mongodb_database["users"].update_one({"username": ldap_user['username']}, {"$set": user})
  203. def disable_wekan_user(username):
  204. print(f"Disabling Wekan user {username}")
  205. stats['disabled'] += 1
  206. mongodb_database["users"].update_one({"username": username}, {"$set": {"loginDisabled": True}})
  207. def create_wekan_team(ldap_group):
  208. print(f"Creating new Wekan team {ldap_group['name']}")
  209. stats['team_created'] += 1
  210. team = { "_id": ldap_group['uuid'],
  211. "teamShortName": ldap_group["name"],
  212. "teamDisplayName": ldap_group["name"],
  213. "teamDesc": ldap_group["description"],
  214. "teamWebsite": "http://localhost",
  215. "teamIsActive": True
  216. }
  217. mongodb_database["team"].insert_one(team)
  218. def update_wekan_team(ldap_group):
  219. updated = False
  220. team = mongodb_database["team"].find_one({"_id": ldap_group['uuid']})
  221. team_tmp = { "_id": ldap_group['uuid'],
  222. "teamShortName": ldap_group["name"],
  223. "teamDisplayName": ldap_group["name"],
  224. "teamDesc": ldap_group["description"],
  225. "teamWebsite": "http://localhost",
  226. "teamIsActive": True
  227. }
  228. for key, value in team_tmp.items():
  229. try:
  230. if team[key] != value:
  231. updated = True
  232. break
  233. except KeyError:
  234. updated = True
  235. if updated:
  236. print(f"Updated Wekan team {ldap_group['name']}")
  237. stats['team_updated'] += 1
  238. mongodb_database["team"].update_one({"_id": ldap_group['uuid']}, {"$set": team_tmp})
  239. def disable_wekan_team(teamname):
  240. print(f"Disabling Wekan team {teamname}")
  241. stats['team_disabled'] += 1
  242. mongodb_database["team"].update_one({"teamShortName": teamname}, {"$set": {"teamIsActive": False}})
  243. def update_wekan_team_memberships(ldap_user):
  244. updated = False
  245. user = mongodb_database["users"].find_one({"username": ldap_user['username']})
  246. teams = user["teams"]
  247. teams_tmp = []
  248. for group in ldap_user["groups"]:
  249. teams_tmp.append({
  250. 'teamId': group['uuid'],
  251. 'teamDisplayName': group['name'],
  252. })
  253. for team in teams_tmp:
  254. if team not in teams:
  255. updated = True
  256. break
  257. if len(teams) != len(teams_tmp):
  258. updated = True
  259. if updated:
  260. print(f"Updated Wekan team memberships for {ldap_user['username']}")
  261. stats['team_membership_update'] += 1
  262. mongodb_database["users"].update_one({"username": ldap_user['username']}, {"$set": { "teams" : teams_tmp }})
  263. def update_wekan_board_memberships(ldap_users):
  264. for board in mongodb_database["boards"].find():
  265. try:
  266. if board['type'] != 'board':
  267. continue
  268. except KeyError:
  269. continue
  270. if not "teams" in board.keys():
  271. continue
  272. members = []
  273. if "members" in board.keys():
  274. members = board["members"]
  275. members_tmp = []
  276. for team in board["teams"]:
  277. for username, user in ldap_users.items():
  278. for group in user["groups"]:
  279. if group['uuid'] == team['teamId']:
  280. user_tmp = {
  281. 'userId': user['uuid'],
  282. 'isAdmin': user['is_superuser'],
  283. 'isActive': True,
  284. 'isNoComments': False,
  285. 'isCommentOnly': False,
  286. 'isWorker': False
  287. }
  288. if user_tmp not in members_tmp:
  289. members_tmp.append(user_tmp.copy())
  290. board_users = []
  291. for card in mongodb_database["cards"].find({"boardId": board['_id']}):
  292. if card['userId'] not in board_users:
  293. board_users.append(card['userId'])
  294. inactive_board_users = board_users.copy()
  295. for member in members_tmp:
  296. if member['userId'] in board_users:
  297. inactive_board_users.remove(member['userId'])
  298. for inactive_board_user in inactive_board_users:
  299. user_tmp = {
  300. 'userId': inactive_board_user,
  301. 'isAdmin': False,
  302. 'isActive': False,
  303. 'isNoComments': False,
  304. 'isCommentOnly': False,
  305. 'isWorker': False
  306. }
  307. if user_tmp not in members_tmp:
  308. members_tmp.append(user_tmp.copy())
  309. if members != members_tmp:
  310. print(f"Updated Wekan board membership for {board['title']}")
  311. stats['board_membership_update'] += 1
  312. mongodb_database["boards"].update_one({"_id": board["_id"]}, {"$set": { "members" : members_tmp }})
  313. def ldap_sync():
  314. print("Fetching users from LDAP")
  315. ldap = LdapConnection()
  316. ldap_users = ldap.get_users()
  317. ldap_username_list = ldap_users.keys()
  318. print("Fetching users from Wekan")
  319. wekan_username_list = []
  320. for user in mongodb_database["users"].find():
  321. if not user['loginDisabled']:
  322. wekan_username_list.append(user['username'])
  323. print("Sorting users")
  324. not_in_ldap = []
  325. not_in_wekan = []
  326. in_wekan = []
  327. for ldap_username in ldap_username_list:
  328. if ldap_username in wekan_username_list:
  329. in_wekan.append(ldap_username)
  330. else:
  331. not_in_wekan.append(ldap_username)
  332. for wekan_username in wekan_username_list:
  333. if wekan_username not in ldap_username_list:
  334. not_in_ldap.append(wekan_username)
  335. print("Fetching groups from LDAP")
  336. ldap_groups = ldap.get_groups()
  337. ldap_groupname_list = ldap_groups.keys()
  338. print("Fetching teams from Wekan")
  339. wekan_teamname_list = []
  340. for team in mongodb_database["team"].find():
  341. if team['teamIsActive']:
  342. wekan_teamname_list.append(team['teamShortName'])
  343. print("Sorting groups")
  344. group_not_in_ldap = []
  345. group_not_in_wekan = []
  346. group_in_wekan = []
  347. for ldap_groupname in ldap_groupname_list:
  348. if ldap_groupname in wekan_teamname_list:
  349. group_in_wekan.append(ldap_groupname)
  350. else:
  351. group_not_in_wekan.append(ldap_groupname)
  352. for wekan_teamname in wekan_teamname_list:
  353. if wekan_teamname not in ldap_groupname_list:
  354. group_not_in_ldap.append(wekan_teamname)
  355. print("Processing users")
  356. for user in not_in_wekan:
  357. create_wekan_user(ldap_users[user])
  358. for user in in_wekan:
  359. update_wekan_user(ldap_users[user])
  360. for user in not_in_ldap:
  361. disable_wekan_user(user)
  362. print("Processing groups")
  363. for group in group_not_in_wekan:
  364. create_wekan_team(ldap_groups[group])
  365. for group in group_in_wekan:
  366. update_wekan_team(ldap_groups[group])
  367. for team in group_not_in_ldap:
  368. disable_wekan_team(team)
  369. for username, user in ldap_users.items():
  370. update_wekan_team_memberships(user)
  371. print("Updating board memberships")
  372. update_wekan_board_memberships(ldap_users)
  373. print()
  374. print(f"Total users considered: {len(ldap_username_list)}")
  375. print(f"Total groups considered: {len(ldap_groups)}")
  376. print(f"Users created {stats['created']}")
  377. print(f"Users updated {stats['updated']}")
  378. print(f"Users disabled {stats['disabled']}")
  379. print(f"Teams created {stats['team_created']}")
  380. print(f"Teams updated {stats['team_updated']}")
  381. print(f"Teams disabled {stats['team_disabled']}")
  382. print(f"Team memberships updated: {stats['team_membership_update']}")
  383. print(f"Board memberships updated: {stats['board_membership_update']}")
  384. if __name__ == "__main__":
  385. ldap_sync()