Skip to content
Snippets Groups Projects
syncldapmemberships.py 5.59 KiB
Newer Older
nd's avatar
nd committed
import logging
nd's avatar
nd committed
import ldap
nd's avatar
nd committed

nd's avatar
nd committed
from django.contrib.auth import get_user_model, load_backend
nd's avatar
nd committed
from django.core.management.base import BaseCommand
nd's avatar
nd committed
from django_auth_ldap.config import LDAPSearch
nd's avatar
nd committed
import django.conf

from urllib.error import HTTPError

nd's avatar
nd committed
from postorius.models import List, MailmanUser
from postorius.utils import get_mailman_client
from allauth.account.models import EmailAddress
nd's avatar
nd committed

nd's avatar
nd committed
logger = logging.getLogger(__name__)
nd's avatar
nd committed

def execute_ldap_search_without_hiding_errors(search, connection, filterargs=(), escape=True):
	'''If an LDAPError occurs during search.execute, it logs the error and returns
	an empty list of results. This behavor is indistinguishable from a query
	with no results.'''
	if not isinstance(search, LDAPSearch):
		raise NotImplementedError(f'{type(search)} is not supported by postorius_ldap_membership_management')
	# This is a copy of django_auth_ldap.config.LDAPSearch.execute without the
	# ldap.LDAPError eating try-except block
	if escape:
		filterargs = search._escape_filterargs(filterargs)
	filterstr = search.filterstr % filterargs
	results = connection.search_s(search.base_dn, search.scope, filterstr, search.attrlist)
	return search._process_results(results)

nd's avatar
nd committed
class Command(BaseCommand):
	can_import_settings = True
	help = 'Synchronize mailing list memberships from a LDAP server'

	def handle(self, *args, **options):
		ldap_backend = load_backend('django_auth_ldap.backend.LDAPBackend')
		conn = ldap_backend.ldap.initialize(ldap_backend.settings.SERVER_URI, bytes_mode=False)
		for opt, value in ldap_backend.settings.CONNECTION_OPTIONS.items():
			conn.set_option(opt, value)
		if ldap_backend.settings.START_TLS:
			conn.start_tls_s()
		conn.simple_bind_s(ldap_backend.settings.BIND_DN, ldap_backend.settings.BIND_PASSWORD)

		client = get_mailman_client()

		django_users = get_user_model().objects.filter(is_active=True)
		backref_mapping = {'member': 'members', 'moderator': 'moderators', 'owner': 'owners'}
nd's avatar
nd committed
		mm_users = {}

		membership_settings = getattr(django.conf.settings, 'LDAP_MEMBERSHIP_SYNC', {})

		# create all django user in mailman
		for user in django_users:
			mm_users[user.username] = MailmanUser.objects.get_or_create_from_django(user)
			if mm_users[user.username].display_name != user.get_full_name():
nd's avatar
nd committed
				logger.warning("update display_name on {} to {}".format(user.username, user.get_full_name()))
nd's avatar
nd committed
				mm_users[user.username].display_name = user.get_full_name()
				mm_users[user.username].save()
			# set user mail adresses to verified if they match those in ldap
			user_emails = EmailAddress.objects.filter(user=user)
			for mail in user_emails:
				if mail.email == user.email and not mail.verified:
					logger.warning("update email.verified on user {} for address {} to True".format(user.username, mail.email))
					mail.verified = True
					mail.save()

nd's avatar
nd committed

		mailman_id2username = {mm_users[i].user_id: i for i in mm_users}

nd's avatar
nd committed
		for list_name in membership_settings:
			ldap_setting = membership_settings[list_name].get('ldap', {})
			for membership_type in ldap_setting:
				if not ldap_setting[membership_type].get('enabled'):
					continue
				ldap_members = execute_ldap_search_without_hiding_errors(LDAPSearch(
nd's avatar
nd committed
						ldap_setting[membership_type]['dn'], ldap.SCOPE_SUBTREE,
						ldap_setting[membership_type]['filter'],
						[ldap_setting[membership_type]['username_attr']]), conn)
				ldap_member_names = [list(attr.values())[0][0] for dn, attr in ldap_members]
nd's avatar
nd committed

				# we refetch the mm_list each time because of wired caching problems
				mm_list = client.get_list(list_name)

				mm_members = getattr(mm_list, backref_mapping[membership_type], [])
				for mm_member in mm_members:
					try:
						username = mailman_id2username.get(mm_member.user.user_id, None)
						if not username or not username in ldap_member_names:
							# user should not be subscribed -> remove
nd's avatar
nd committed
							logger.warning("remove {} ( {} ) as {} on {}".format(username, mm_member.user, membership_type, list_name))
nd's avatar
nd committed
							mm_member.unsubscribe()
					except Exception as e:
						logger.exception(e)
						continue
nd's avatar
nd committed

nd's avatar
nd committed
				for username in mm_users:
					if not username in ldap_member_names:
						continue
					try:
						mm_user = mm_users[username]
						displayname = mm_users[username].display_name or username
						try:
							# user might not be subscribed but should be subscribed -> subscribe
							# we do not test if he is subscibed already because the mailman api is inconsistent and reports wrong state information for this...
							# instead we always try to subscribe and catch the error if we are subscribed already
								user_mail = address.email
								if membership_type == 'member':
									# discard a subscription request before trying to subscribe else it will fail
									for request in mm_list.requests:
										if request['email'] == user_mail:
											mm_list.discard_request(request['token'])
									mm_list.subscribe(user_mail,
											display_name=displayname,
											pre_verified=True,
											pre_confirmed=True,
											pre_approved=True)
								elif membership_type == 'moderator':
									mm_list.add_moderator(user_mail,
											display_name=displayname)
								elif membership_type == 'owner':
psy's avatar
psy committed
									mm_list.add_owner(user_mail,
											display_name=displayname)
								logger.warning("subscribe {} ( {} ) as {} on {}".format(username, user_mail, membership_type, list_name))
						except HTTPError as e:
							# We get a http error if the user is already subscribed.
							# It is silently ignored
							if e.code == 409 and e.reason == b'Member already subscribed':
								continue
nd's avatar
nd committed
					except Exception as e:
						logger.exception(e)
						continue