From ef5a108b693e45414377d29ee0930bb126159aa7 Mon Sep 17 00:00:00 2001 From: Tanner Prestegard <tanner.prestegard@ligo.org> Date: Mon, 3 Jun 2019 14:40:32 -0500 Subject: [PATCH] Update user account "update from LDAP" script Needs to work properly with new AuthGroup setup and X509Cert model changes --- .../update_user_accounts_from_ligo_ldap.py | 516 +++++++++++++----- 1 file changed, 369 insertions(+), 147 deletions(-) diff --git a/gracedb/ligoauth/management/commands/update_user_accounts_from_ligo_ldap.py b/gracedb/ligoauth/management/commands/update_user_accounts_from_ligo_ldap.py index c35b7a274..1ed0b09bc 100644 --- a/gracedb/ligoauth/management/commands/update_user_accounts_from_ligo_ldap.py +++ b/gracedb/ligoauth/management/commands/update_user_accounts_from_ligo_ldap.py @@ -2,169 +2,391 @@ import datetime import ldap from django.conf import settings -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand, CommandError from ligoauth.models import LigoLdapUser, X509Cert, AuthGroup +UserModel = get_user_model() + + +# Classes for processing LDAP results into user accounts ---------------------- +class LdapPersonResultProcessor(object): + stdout = None + + def __init__(self, ldap_dn, ldap_result, ldap_connection=None, + verbose=True, stdout=None, *args, **kwargs): + super(LdapPersonResultProcessor, self).__init__(*args, **kwargs) + self.ldap_dn = ldap_dn + self.ldap_result = ldap_result + self.verbose = verbose + self.ldap_connection = ldap_connection + self.stdout = stdout + + def write(self, message): + if self.stdout: + self.stdout.write(message) + else: + print(message) + + def extract_user_attributes(self): + if self.ldap_connection is None: + raise RuntimeError('LDAP connection not configured') + self.user_data = { + 'first_name': unicode(self.ldap_result['givenName'][0], 'utf-8'), + 'last_name': unicode(self.ldap_result['sn'][0], 'utf-8'), + 'email': self.ldap_result['mail'][0], + 'is_active': self.ldap_connection.lvc_group.ldap_name in + self.ldap_result.get('isMemberOf', []), + 'username': self.ldap_result['krbPrincipalName'][0], + } + + def check_situation(self, user_exists, l_user_exists): + pass + + def get_or_create_user(self): + if not hasattr(self, 'user_data'): + self.extract_user_attributes() + + # Determine if users exist + user_exists = UserModel.objects.filter(username= + self.user_data['username']).exists() + l_user_exists = LigoLdapUser.objects.filter( + ldap_dn=self.ldap_dn).exists() + + # Run any necessary checks at this point + self.check_situation(user_exists, l_user_exists) + + # Handle different cases + self.user_created = False + if l_user_exists: + # LigoLdapUser exists already (and thus, User object exists too) + l_user = LigoLdapUser.objects.get(ldap_dn=self.ldap_dn) + user = l_user.user_ptr + else: + # LigoLdapUser doesn't exist + if user_exists: + # User object exists, though, so we have to carefully create + # the LigoLdapUser object + user = UserModel.objects.get(username= + self.user_data['username']) + l_user = LigoLdapUser(ldap_dn=self.ldap_dn, user_ptr=user) + l_user.__dict__.update(user.__dict__) + l_user.save() + if self.verbose: + self.write("Created ligoldapuser for {0}".format( + user.username)) + else: + # No User object either, so we do a simple creation + l_user = LigoLdapUser.objects.create(ldap_dn=self.ldap_dn, + **self.user_data) + user = l_user.user_ptr + if self.verbose: + self.write("Created user and ligoldapuser for {0}".format( + l_user.username)) + self.user_created = True + + self.ligoldapuser = l_user + self.user = user + + self.check_user_accounts() + + def check_user_accounts(self): + if not (hasattr(self, 'user_data') or hasattr(self, 'user')): + raise RuntimeError('User data or user object missing') + if (self.user.username != self.user_data['username'] and + self.user_exists): + self.write(('ERROR: requires manual investigation. LDAP ' + 'username: {0}, ligoldapuser.user_ptr.username: {1}') + .format(self.user_data['username'], + self.ligoldapuser.user_ptr.username)) + raise UserConfigError('User configuration error') + + def update_user(self): + if not hasattr(self, 'user'): + raise RuntimeError('User object missing') + self.update_user_attributes() + self.update_user_groups() + self.update_user_certificates() + + def get_attributes_to_update(self): + attributes_to_update = [k for k,v in self.user_data.items() + if v != getattr(self.ligoldapuser, k)] + return attributes_to_update + + def update_user_attributes(self): + self.user_changed = False + + # Don't do anything if the user was just created + if self.user_created: + return + + # Update attributes + attributes_to_update = self.get_attributes_to_update() + if attributes_to_update: + self.user_changed = True + for k in attributes_to_update: + setattr(self.ligoldapuser, k, self.user_data[k]) + + # Revoke staff/superuser if not active. + if ((self.ligoldapuser.is_staff or self.ligoldapuser.is_superuser) + and not self.user_data['is_active']): + self.ligoldapuser.is_staff = self.ligoldapuser.is_superuser = False + self.user_changed = True + + if self.user_changed and self.verbose: + self.write("User {0} updated".format(self.ligoldapuser.username)) + + def update_user_groups(self): + # Get list of group names that the user belongs to from the LDAP result + memberships = self.ldap_result.get('isMemberOf', []) + + # Get groups which are listed for the user in the LDAP and whose + # membership is controlled by the LDAP + ldap_groups_to_add = self.ldap_connection.groups.filter(ldap_name__in= + memberships).exclude(pk__in=self.ligoldapuser.groups.all()) + + # Add the user to these groups + if self.verbose and ldap_groups_to_add.exists(): + self.write("Adding {0} to {1}".format(self.ligoldapuser.username, + " and ".join(list(ldap_groups_to_add.values_list( + 'name', flat=True))))) + self.ligoldapuser.groups.add(*ldap_groups_to_add) + + # Get groups which are *not* listed for the user in the LDAP and whose + # membership is controlled by the LDAP + ldap_groups_to_remove = self.ligoldapuser.groups.filter(pk__in= + self.ldap_connection.groups.exclude(ldap_name__in=memberships)) + + # Remove the user from these groups + if self.verbose and ldap_groups_to_remove.exists(): + self.write("Removing {0} from {1}".format( + self.ligoldapuser.username, + " and ".join(list(ldap_groups_to_remove.values_list( + 'name', flat=True))))) + self.ligoldapuser.groups.remove(*ldap_groups_to_remove) + + def add_certs(self, certs): + # Add new certificates to user + for subject in certs: + if self.verbose: + self.write('Creating certificate with subject {0} for {1}' + .format(subject, self.ligoldapuser.username)) + cert, _ = X509Cert.objects.get_or_create(subject=subject, + user=self.ligoldapuser) + + def remove_certs(self, certs): + # Remove old certificates from user + # NOTE: we just delete these certs. They will be created again + # if another user adds them. This helps to keep random certificates + # from floating around without a user. + for subject in certs: + if self.verbose: + self.write('Deleting certificate with subject {0} for {1}' + .format(subject, self.ligoldapuser.username)) + cert = self.ligoldapuser.x509cert_set.get(subject=subject) + cert.delete() + + def update_user_certificates(self): + # Get two lists of subjects as sets + db_x509_subjects = set(list(self.ligoldapuser.x509cert_set.values_list( + 'subject', flat=True))) + ldap_x509_subjects = set(self.ldap_result.get('gridX509subject', [])) + + # Get certs to add and remove + certs_to_add = ldap_x509_subjects.difference(db_x509_subjects) + certs_to_remove = db_x509_subjects.difference(ldap_x509_subjects) + + # Add and remove certificates + self.add_certs(certs_to_add) + self.remove_certs(certs_to_remove) + + def save_user(self): + if self.user_created or self.user_changed: + try: + self.ligoldapuser.save() + except Exception as e: + self.write("Failed to save user '{0}': {1}.".format( + self.ligoldapuser.username, e)) + + class UserConfigError(Exception): + pass + + class UnacceptableUserError(Exception): + pass + + +class LdapRobotResultProcessor(LdapPersonResultProcessor): + + def extract_user_attributes(self): + if self.ldap_connection is None: + raise RuntimeError('LDAP connection not configured') + self.user_data = { + 'last_name': unicode(self.ldap_result['x-LIGO-TWikiName'][0], + 'utf-8'), + 'email': self.ldap_result['mail'][0], + 'is_active': self.ldap_connection.groups.get( + name='robot_accounts').name in self.ldap_result.get( + 'isMemberOf', []), + 'username': self.ldap_result['cn'][0], + } + + def check_situation(self, user_exists, l_user_exists): + if (not (user_exists or l_user_exists) and not self.user_data['is_active']): + err_msg = 'User {0} should not be added to the DB'.format( + self.user_data['username']) + self.write(err_msg) + raise self.UnacceptableUserError(err_msg) + + def get_attributes_to_update(self): + attributes_to_update = [k for k in ['email', 'is_active'] + if self.user_data[k] != getattr(self.ligoldapuser, k)] + return attributes_to_update + + def remove_certs(self, certs): + pass + # NOTE: for now (2019) we don't remove any robot certificates since + # there arestill some old LIGO CA certificates in use that aren't in + # the LDAP + + +# Classes for handling LDAP connections and queries --------------------------- +class LigoPeopleLdap(object): + name = 'people' + ldap_host = "ldaps://ldap.ligo.org" + ldap_port = 636 + ldap_protocol_version = ldap.VERSION3 + base_dn = "ou=people,dc=ligo,dc=org" + search_filter = "(employeeNumber=*)" + search_scope = ldap.SCOPE_SUBTREE + attribute_list = [ + "krbPrincipalName", + "gridX509subject", + "givenName", + "sn", + "mail", + "isMemberOf", + ] + group_names = ['internal_users', 'em_advocates'] + user_processor_class = LdapPersonResultProcessor + + def __init__(self, verbose=True, *args, **kwargs): + super(LigoPeopleLdap, self).__init__(*args, **kwargs) + # Check configuration + if not self.base_dn: + raise ValueError('self.base_dn must be set') + #if not self.attribute_list: + # raise ValueError('self.attribute_list must be set') + + # Set up groups + self.groups = AuthGroup.objects.filter(name__in=self.group_names) + self.lvc_group = AuthGroup.objects.get(name=settings.LVC_GROUP) + + self.verbose = verbose + + def initialize(self): + ldap_address = '{host}:{port}'.format(host=self.ldap_host, + port=self.ldap_port) + self.ldap_object = ldap.initialize(ldap_address) + self.ldap_object.protocol_version = self.ldap_protocol_version + + def initialize_user_processor(self, *args, **kwargs): + self.user_processor = self.user_processor_class(*args, **kwargs) + self.user_processor.ldap_connection = self + return self.user_processor + + def perform_query(self): + ldap_result_id = self.ldap_object.search(self.base_dn, + self.search_scope, filterstr=self.search_filter, + attrlist=self.attribute_list) + result_type, result_data = self.ldap_object.result(ldap_result_id, + all=1, timeout=30) + + if not result_type == ldap.RES_SEARCH_RESULT: + err_msg = 'Unexpected result type ({rt}) from LDAP query'.format( + rt=result_type) + raise TypeError(err_msg) + + return result_data + + +class LigoRobotsLdap(LigoPeopleLdap): + name = 'robots' + base_dn = "ou=keytab,ou=robot,dc=ligo,dc=org" + search_filter = "(cn=*)" + search_scope = ldap.SCOPE_SUBTREE + attribute_list = [ + 'cn', + 'uid', + 'gridX509subject', + 'mail', + 'isMemberOf', + 'x-LIGO-TWikiName', + ] + group_names = ['internal_users', 'robot_accounts'] + user_processor_class = LdapRobotResultProcessor + + +# Dict of LDAP classes with names as keys +# NOTE: not using robot OU right now since we are waiting +# for some auth infrastructure changes to be able to properly group +# certificates into a user account +#LDAP_CLASSES = {l.name: l for l in (LigoPeopleLdap, LigoRobotsLdap)} +LDAP_CLASSES = {l.name: l for l in (LigoPeopleLdap,)} -# Variables for LDAP search -BASE_DN = "ou=people,dc=ligo,dc=org" -SEARCH_SCOPE = ldap.SCOPE_SUBTREE -SEARCH_FILTER = "(employeeNumber=*)" -RETRIEVE_ATTRIBUTES = [ - "krbPrincipalName", - "gridX509subject", - "givenName", - "sn", - "mail", - "isMemberOf", -] -LDAP_ADDRESS = "ldap.ligo.org" -LDAP_PORT = 636 class Command(BaseCommand): help="Get updated user data from LIGO LDAP" def add_arguments(self, parser): + parser.add_argument('ldap', choices=list(LDAP_CLASSES), + help="Name of LDAP to use") parser.add_argument('-q', '--quiet', action='store_true', default=False, help='Suppress output') def handle(self, *args, **options): + if options['ldap'] == 'robots': + raise ValueError('Not properly set up for robot OU') verbose = not options['quiet'] if verbose: self.stdout.write('Refreshing users from LIGO LDAP at {0}' \ .format(datetime.datetime.utcnow())) - # Get LVC group - lvc_group = Group.objects.get(name=settings.LVC_GROUP) - - # Open connection to LDAP and run a search - l = ldap.initialize("ldaps://{address}:{port}".format( - address=LDAP_ADDRESS, port=LDAP_PORT)) - l.protocol_version = ldap.VERSION3 - ldap_result_id = l.search(BASE_DN, SEARCH_SCOPE, SEARCH_FILTER, - RETRIEVE_ATTRIBUTES) - - # Get all results - result_data = True - while result_data: - result_type, result_data = l.result(ldap_result_id, 0) - - if result_type == ldap.RES_SEARCH_ENTRY: - for (ldap_dn, ldap_result) in result_data: - first_name = unicode(ldap_result['givenName'][0], 'utf-8') - last_name = unicode(ldap_result['sn'][0], 'utf-8') - email = ldap_result['mail'][0] - new_dns = set(ldap_result.get('gridX509subject', [])) - memberships = ldap_result.get('isMemberOf', []) - is_active = lvc_group.name in memberships - principal = ldap_result['krbPrincipalName'][0] - - # Update/Create LigoLdapUser entry - defaults = { - 'first_name': first_name, - 'last_name': last_name, - 'email': email, - 'username': principal, - 'is_active': is_active, - } - - # Determine if base user and ligoldapuser objects exist - user_exists = User.objects.filter(username= - defaults['username']).exists() - l_user_exists = LigoLdapUser.objects.filter( - ldap_dn=ldap_dn).exists() - - # Handle different cases - created = False - if l_user_exists: - l_user = LigoLdapUser.objects.get(ldap_dn=ldap_dn) - user = l_user.user_ptr - else: - if user_exists: - user = User.objects.get(username= - defaults['username']) - l_user = LigoLdapUser.objects.create( - ldap_dn=ldap_dn, user_ptr=user) - l_user.__dict__.update(user.__dict__) - if verbose: - self.stdout.write(("Created ligoldapuser " - "for {0}").format(user.username)) - else: - l_user = LigoLdapUser.objects.create( - ldap_dn=ldap_dn, **defaults) - user = l_user.user_ptr - if verbose: - self.stdout.write(("Created user and " - "ligoldapuser for {0}").format( - l_user.username)) - created = True - - # Typically a case where the person's username was changed - # and there are now two user accounts in GraceDB - if user.username != defaults['username'] and user_exists: - self.stdout.write(('ERROR: requires manual ' - 'investigation. LDAP username: {0}, ' - 'ligoldapuser.user_ptr.username: {1}').format( - defaults['username'], l_user.user_ptr.username)) - continue - - # Update user attributes from LDAP - changed = False - if not created: - for k in defaults: - if (defaults[k] != getattr(user, k)): - setattr(l_user, k, defaults[k]) - changed = True - if changed and verbose: - self.stdout.write("User {0} updated".format( - l_user.username)) - - # Revoke staff/superuser if not active. - if ((l_user.is_staff or l_user.is_superuser) - and not is_active): - l_user.is_staff = l_user.is_superuser = False - changed = True - - # Try to save user. - if created or changed: - try: - l_user.save() - except Exception as e: - self.stdout.write(("Failed to save user '{0}': " - "{1}.").format(l_user.username, e)) - continue - - # Update X509 certs for user - current_dns = set([c.subject for c in - user.x509cert_set.all()]) - if current_dns != new_dns: - for dn in new_dns - current_dns: - cert, created = X509Cert.objects.get_or_create( - subject=dn) - cert.users.add(l_user) - - # Update group information - we do this only for groups - # that already exist in GraceDB - for g in Group.objects.all(): - # Add the user to the group if they aren't a member - if (g.name in memberships and g not in - l_user.groups.all()): - - g.user_set.add(l_user) - if verbose: - self.stdout.write("Adding {0} to {1}".format( - l_user.username, g.name)) - - # Remove the user from the LVC group if they are no longer - # a member. This is the only group in GraceDB which is - # populated from the LIGO LDAP. - if (lvc_group.name not in memberships and - lvc_group in l_user.groups.all()): - - l_user.groups.remove(lvc_group) - if verbose: - self.stdout.write("Removing {user} from {group}" \ - .format(user=l_user.username, - group=lvc_group.name)) + # Set up ldap connection + ldap_connection = LDAP_CLASSES[options['ldap']](verbose=verbose) + ldap_connection.initialize() + + # Perform query and get all results + result_data = ldap_connection.perform_query() + + # Loop over results + for ldap_dn, ldap_result in result_data: + # Set up user processor + user_processor = ldap_connection.initialize_user_processor(ldap_dn, + ldap_result, verbose=verbose, stdout=self.stdout) + + # Get or create user - if an error occurs, continue. + # Details should already be written to the log. + try: + user_processor.extract_user_attributes() + user_processor.get_or_create_user() + except user_processor.UserConfigError as e: + continue + except user_processor.UnacceptableUserError as e: + # Indicates that the user shouldn't be added + continue + + # Update user based on LDAP information - this includes + # attributes, group memberships, X509 certificates, etc. + user_processor.update_user() + + # Try to save user + user_processor.save_user() + + # Extra stuff + if (ldap_connection.name == 'robots'): + pass + # NOTE: eventually (i.e., once all of the old LIGO.ORG certificates + # are expired), we will want to remove any robot certificates which + # aren't found in this LDAP query (effectively deactivating any + # accounts which have no certificates attached to them). -- GitLab