diff --git a/coldfront/config/logging.py b/coldfront/config/logging.py index caa2b1d0b..dcba77f27 100644 --- a/coldfront/config/logging.py +++ b/coldfront/config/logging.py @@ -15,7 +15,13 @@ LOGGING = { 'version': 1, 'disable_existing_loggers': False, - 'formatters': {}, + 'formatters': { + 'key-events': { + "()": "django.utils.log.ServerFormatter", + "format": "[{server_time}] {levelname} {message}", + "style": "{", + } + }, 'handlers': { 'console': { 'class': 'logging.StreamHandler', @@ -24,12 +30,17 @@ 'class': 'logging.FileHandler', 'filename': 'django-q.log', }, + 'key-events': { + 'class': 'logging.handlers.TimedRotatingFileHandler', + 'filename': 'logs/key-events.log', + 'when': 'D', + 'formatter': 'key-events', + }, # 'file': { # 'class': 'logging.FileHandler', # 'filename': '/tmp/debug.log', # }, }, - 'formatters': {}, 'loggers': { 'django_auth_ldap': { 'level': 'INFO', @@ -52,5 +63,9 @@ 'handlers': ['console'], 'level': 'INFO', }, + 'coldfront.core.project': { + 'handlers': ['key-events'], + 'level': 'INFO', + } }, } diff --git a/coldfront/core/allocation/models.py b/coldfront/core/allocation/models.py index 3af543b26..6c1113d9b 100644 --- a/coldfront/core/allocation/models.py +++ b/coldfront/core/allocation/models.py @@ -202,6 +202,8 @@ def cost(self): price = float(get_resource_rate(self.resources.first().name)) except AttributeError: return None + except TypeError: + return None size = self.allocationattribute_set.get(allocation_attribute_type_id=1).value return 0 if not size else price * float(size) diff --git a/coldfront/core/allocation/views.py b/coldfront/core/allocation/views.py index 2db3e987d..6cb534acc 100644 --- a/coldfront/core/allocation/views.py +++ b/coldfront/core/allocation/views.py @@ -791,10 +791,11 @@ def post(self, request, *args, **kwargs): cleaned_form = [form.cleaned_data for form in formset] selected_cleaned_form = [form for form in cleaned_form if form['selected']] for form_data in selected_cleaned_form: - users_added_count += 1 + user_obj = get_user_model().objects.get( username=form_data.get('username') ) + allocation_user_obj, _ = ( allocation_obj.allocationuser_set.update_or_create( user=user_obj, defaults={'status': user_active_status} @@ -803,6 +804,7 @@ def post(self, request, *args, **kwargs): allocation_activate_user.send( sender=self.__class__, allocation_user_pk=allocation_user_obj.pk ) + users_added_count += 1 user_plural = 'user' if users_added_count == 1 else 'users' msg = f'Added {users_added_count} {user_plural} to allocation.' @@ -899,7 +901,6 @@ def post(self, request, *args, **kwargs): form for form in cleaned_forms if form['selected'] ] for user_form_data in selected_cleaned_forms: - remove_users_count += 1 user_obj = get_user_model().objects.get( username=user_form_data.get('username') ) @@ -909,11 +910,13 @@ def post(self, request, *args, **kwargs): allocation_user_obj = allocation_obj.allocationuser_set.get( user=user_obj ) + allocation_user_obj.status = removed_allocuser_status allocation_user_obj.save() allocation_remove_user.send( sender=self.__class__, allocation_user_pk=allocation_user_obj.pk ) + remove_users_count += 1 user_plural = 'user' if remove_users_count == 1 else 'users' msg = f'Removed {remove_users_count} {user_plural} from allocation.' diff --git a/coldfront/core/project/templates/project/project_add_users.html b/coldfront/core/project/templates/project/project_add_users.html index 77b234c56..b3cf5d024 100644 --- a/coldfront/core/project/templates/project/project_add_users.html +++ b/coldfront/core/project/templates/project/project_add_users.html @@ -12,6 +12,9 @@ <h2>Add users to project: {{project.title}}</h2> <hr> +<p> + Adding a user to your project gives them the ability to access and use your project's allocations. +</p> <div class="row"> <div class="col"> <form method="post" action="/search-ldap" id="post-form"> @@ -70,9 +73,9 @@ <h2>Add users to project: {{project.title}}</h2> $.ajax({ url : "/project/" + pk + "/add-users-search-results/", // the endpoint type : "POST", // http method - data : { - q : q, - search_by : search_by, + data : { + q : q, + search_by : search_by, csrfmiddlewaretoken: "{{ csrf_token }}" }, // data sent with the post request // handle a successful response @@ -99,7 +102,7 @@ <h2>Add users to project: {{project.title}}</h2> $(document).ready(function(){ $('#search_results').hide(); $('#selected_users').hide(); - $('[data-toggle="popover"]').popover(); + $('[data-toggle="popover"]').popover(); }); </script> {% endblock %} diff --git a/coldfront/core/project/templates/project/project_detail.html b/coldfront/core/project/templates/project/project_detail.html index 3b660022d..1da913b84 100644 --- a/coldfront/core/project/templates/project/project_detail.html +++ b/coldfront/core/project/templates/project/project_detail.html @@ -247,15 +247,13 @@ <h3 class="d-inline"><i class="fas fa-file-invoice-dollar" aria-hidden="true"></ <div class="card mb-3"> <div class="card-header"> <h3 class="d-inline" id="users"><i class="fas fa-users" aria-hidden="true"></i> Users</h3> <span class="badge badge-secondary">{{project_users.count}}</span> - <span class="float-right">Last Sync: {{user_sync_dt}}</span> + <span class="d-inline float-none">Last Sync: {{user_sync_dt}}</span> <div class="float-right"> - {% comment %} {% if project.status.name != 'Archived' and is_allowed_to_update_project %} - <a style="visibility: hidden" class="btn btn-primary" href="{{mailto}}" role="button"><i class="far fa-envelope" aria-hidden="true"></i> Email Project Users</a> - <a style="visibility: hidden" class="btn btn-success" href="{% url 'project-add-users-search' project.id %}" role="button"><i class="fas fa-user-plus" aria-hidden="true"></i> Add Users</a> - <a style="visibility: hidden" class="btn btn-danger" href="{% url 'project-remove-users' project.id %}" role="button"><i class="fas fa-user-times" aria-hidden="true"></i> Remove Users</a> + <a class="btn btn-primary" href="{{mailto}}" role="button"><i class="far fa-envelope" aria-hidden="true"></i> Email Project Users</a> + <a class="btn btn-success" href="{% url 'project-add-users-search' project.id %}" role="button"><i class="fas fa-user-plus" aria-hidden="true"></i> Add Users</a> + <a class="btn btn-danger" href="{% url 'project-remove-users' project.id %}" role="button"><i class="fas fa-user-times" aria-hidden="true"></i> Remove Users</a> {% endif %} - {% endcomment %} </div> </div> <div class="card-body"> diff --git a/coldfront/core/project/templates/project/project_remove_users.html b/coldfront/core/project/templates/project/project_remove_users.html index 530d78f2d..9aa1bb2b8 100644 --- a/coldfront/core/project/templates/project/project_remove_users.html +++ b/coldfront/core/project/templates/project/project_remove_users.html @@ -8,10 +8,20 @@ <h2>Remove users from project: {{project.title}}</h2> <hr> -{% if formset %} +{% if formset or users_no_removal %} <div class="card border-light"> <div class="card-body"> - + <p> + Project users have the ability to access project allocations. Removing a user here + will remove the user's access to your project's allocations, but will not + remove that user's data from the allocations. + </p> + <p> + To be removed from a lab, the user must not have the lab as their primary + group. If you would like to remove a user that has your lab as their primary + group, please <a href="https://portal.rc.fas.harvard.edu/rcrt/submit_ticket"> + contact FASRC support</a>. + </p> <form action="{% url 'project-remove-users' project.pk %}" method="post"> {% csrf_token %} <div class="table-responsive"> @@ -30,6 +40,17 @@ <h2>Remove users from project: {{project.title}}</h2> </tr> </thead> <tbody> + {% for user in users_no_removal %} + <tr> + <td></td> + <td></td> + <td style="color: gray;">{{ user.username }}</td> + <td style="color: gray;">{{ user.first_name }}</td> + <td style="color: gray;">{{ user.last_name }}</td> + <td style="color: gray;">{{ user.email }}</td> + <td style="color: gray;">{{ user.role }}</td> + </tr> + {% endfor %} {% for form in formset %} <tr> <td>{{ form.selected }}</td> @@ -54,7 +75,7 @@ <h2>Remove users from project: {{project.title}}</h2> </div> </div> {% else %} - <a class="btn btn-secondary mb-3" href="{% url 'project-detail' project.pk %}" role="button"><i class="fas fa-long-arrow-left" aria-hidden="true"></i> Back to Project</a> + <a class="btn btn-secondary mb-3" href="{% url 'project-detail' project.pk %}" role="button"><i class="fas fa-long-arrow-left" aria-hidden="true"></i> Back to Project</a> <div class="alert alert-info"> No users to remove! </div> diff --git a/coldfront/core/project/test_views.py b/coldfront/core/project/test_views.py index b60fab006..ba3251c13 100644 --- a/coldfront/core/project/test_views.py +++ b/coldfront/core/project/test_views.py @@ -1,13 +1,11 @@ import logging -from django.test import TestCase +from django.test import TestCase, tag from coldfront.core.test_helpers import utils from coldfront.core.test_helpers.factories import ( setup_models, - UserFactory, ProjectFactory, - ProjectUserFactory, PAttributeTypeFactory, ProjectAttributeFactory, ProjectStatusChoiceFactory, @@ -353,6 +351,7 @@ def setUp(self): """set up users and project for testing""" self.url = f'/project/{self.project.pk}/remove-users/' + @tag('net') def test_projectremoveusersview_access(self): """test access to project remove users page""" self.project_access_tstbase(self.url) diff --git a/coldfront/core/project/views.py b/coldfront/core/project/views.py index 5e753a166..8fb5d4baf 100644 --- a/coldfront/core/project/views.py +++ b/coldfront/core/project/views.py @@ -10,8 +10,7 @@ from django.contrib.messages.views import SuccessMessageMixin from django.db.models import Q from django.forms import formset_factory -from django.http import (HttpResponse, - HttpResponseRedirect) +from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse, reverse_lazy from django.views import View @@ -62,11 +61,15 @@ from coldfront.core.user.utils import CombinedUserSearch from coldfront.core.utils.views import ColdfrontListView, NoteCreateView, NoteUpdateView from coldfront.core.utils.common import get_domain_url, import_from_settings +from coldfront.core.utils.fasrc import sort_by from coldfront.core.utils.mail import send_email, send_email_template if 'django_q' in settings.INSTALLED_APPS: from django_q.tasks import Task +if 'coldfront.plugins.ldap' in settings.INSTALLED_APPS: + from coldfront.plugins.ldap.utils import LDAPConn + ALLOCATION_ENABLE_ALLOCATION_RENEWAL = import_from_settings( 'ALLOCATION_ENABLE_ALLOCATION_RENEWAL', True) ALLOCATION_DEFAULT_ALLOCATION_LENGTH = import_from_settings( @@ -91,8 +94,6 @@ def produce_filter_parameter(key, value): logger = logging.getLogger(__name__) -logger = logging.getLogger(__name__) - class ProjectDetailView(LoginRequiredMixin, UserPassesTestMixin, DetailView): model = Project @@ -632,6 +633,9 @@ def post(self, request, *args, **kwargs): ) added_users_count = 0 + if 'coldfront.plugins.ldap' in settings.INSTALLED_APPS: + ldap_conn = LDAPConn() + if formset.is_valid() and allocation_form.is_valid(): projuserstatus_active = ProjectUserStatusChoice.objects.get(name='Active') allocuser_status_active = AllocationUserStatusChoice.objects.get( @@ -643,34 +647,58 @@ def post(self, request, *args, **kwargs): for form in formset: user_form_data = form.cleaned_data if user_form_data['selected']: - added_users_count += 1 # Will create local copy of user if not already present in local database - user_obj, _ = get_user_model().objects.get_or_create( - username=user_form_data.get('username') - ) - user_obj.first_name = user_form_data.get('first_name') - user_obj.last_name = user_form_data.get('last_name') - user_obj.email = user_form_data.get('email') - user_obj.save() + # user_obj, _ = get_user_model().objects.update_or_create( + # username=user_form_data.get('username'), + # defaults={ + # 'first_name': user_form_data.get('first_name'), + # 'last_name': user_form_data.get('last_name'), + # 'email': user_form_data.get('email'), + # } + # ) + # FASRC Coldfront doesn't add user entries due to internal syncing + user_username = user_form_data.get('username') + try: + user_obj = get_user_model().objects.get(username=user_username) + except Exception as e: + error = f"Could not locate user for {user_username}: {e}" + logger.error('P665: %s', error) + messages.error(request, error) + continue role_choice = user_form_data.get('role') + + if 'coldfront.plugins.ldap' in settings.INSTALLED_APPS: + try: + ldap_conn.add_member_to_group( + user_obj.username, project_obj.title, + ) + logger.info( + "P678: Coldfront user %s added AD User for %s to AD Group %s", + self.request.user, + user_obj.username, + project_obj.title, + ) + except Exception as e: + error = f"Could not add user {user_obj} to AD Group for {project_obj.title}: {e}" + logger.error( + "P685: user %s could not add AD user of %s to AD Group of %s: %s", + self.request.user, user_obj, project_obj.title, e + ) + messages.error(request, error) + continue + # Is the user already in the project? - if project_obj.projectuser_set.filter(user=user_obj).exists(): - project_user_obj = project_obj.projectuser_set.get( - user=user_obj - ) - project_user_obj.role = role_choice - project_user_obj.status = projuserstatus_active - project_user_obj.save() - else: - project_user_obj = ProjectUser.objects.create( - user=user_obj, - project=project_obj, - role=role_choice, - status=projuserstatus_active, - ) + project_obj.projectuser_set.update_or_create( + user=user_obj, + defaults={ + 'role': role_choice, + 'status': projuserstatus_active, + } + ) + added_users_count += 1 for allocation in Allocation.objects.filter( pk__in=allocation_form_data ): @@ -742,6 +770,18 @@ def get(self, request, *args, **kwargs): pk = self.kwargs.get('pk') project_obj = get_object_or_404(Project, pk=pk) users_to_remove = self.get_users_to_remove(project_obj) + users_no_removal = None + + # if ldap is activated, prevent + if 'coldfront.plugins.ldap' in settings.INSTALLED_APPS: + + usernames = [u['username'] for u in users_to_remove] + ldap_conn = LDAPConn() + users_main_group = ldap_conn.users_in_primary_group( + usernames, project_obj.title) + ingroup = lambda u: u['username'] in users_main_group + users_no_removal, users_to_remove = sort_by(users_to_remove, ingroup, how="condition") + context = {} if users_to_remove: @@ -752,6 +792,7 @@ def get(self, request, *args, **kwargs): context['formset'] = formset context['project'] = get_object_or_404(Project, pk=pk) + context['users_no_removal'] = users_no_removal return render(request, self.template_name, context) def post(self, request, *args, **kwargs): @@ -764,6 +805,8 @@ def post(self, request, *args, **kwargs): formset = formset(request.POST, initial=users_to_remove, prefix='userform') remove_users_count = 0 + if 'coldfront.plugins.ldap' in settings.INSTALLED_APPS: + ldap_conn = LDAPConn() if formset.is_valid(): projectuser_status_removed = ProjectUserStatusChoice.objects.get( @@ -775,7 +818,6 @@ def post(self, request, *args, **kwargs): for form in formset: user_form_data = form.cleaned_data if user_form_data['selected']: - remove_users_count += 1 user_obj = get_user_model().objects.get( username=user_form_data.get('username') ) @@ -783,6 +825,32 @@ def post(self, request, *args, **kwargs): continue project_user_obj = project_obj.projectuser_set.get(user=user_obj) + + if 'coldfront.plugins.ldap' in settings.INSTALLED_APPS: + try: + ldap_conn.remove_member_from_group( + user_obj.username, project_obj.title, + ) + logger.info( + "P835: Coldfront user %s removed AD User for %s from AD Group for %s", + self.request.user, + user_obj.username, + project_obj.title, + ) + except Exception as e: + messages.error( + request, + f"could not remove user {user_obj}: {e}" + ) + logger.error( + "P846: Coldfront user %s could NOT remove AD User for %s from AD Group for %s: %s", + self.request.user, + user_obj.username, + project_obj.title, + e + ) + continue + project_user_obj.status = projectuser_status_removed project_user_obj.save() @@ -800,6 +868,7 @@ def post(self, request, *args, **kwargs): allocation_remove_user.send( sender=self.__class__, allocation_user_pk=alloc_user.pk ) + remove_users_count += 1 user_pl = 'user' if remove_users_count == 1 else 'users' messages.success( request, f'Removed {remove_users_count} {user_pl} from project.' diff --git a/coldfront/plugins/ldap/README.md b/coldfront/plugins/ldap/README.md index 437cbaafc..b5aa47e79 100644 --- a/coldfront/plugins/ldap/README.md +++ b/coldfront/plugins/ldap/README.md @@ -11,3 +11,10 @@ Add the following variables to your .env: You may also add the following variables to your .env: - AUTH_LDAP_USE_SSL (default will be False) + +To enable testing with a test LDAP server, add: +- TEST_AUTH_LDAP_SERVER_URI +- TEST_AUTH_LDAP_BIND_DN +- TEST_AUTH_LDAP_BIND_PASSWORD +- TEST_AUTH_LDAP_USER_SEARCH_BASE +- TEST_AUTH_LDAP_GROUP_SEARCH_BASE diff --git a/coldfront/plugins/ldap/tests.py b/coldfront/plugins/ldap/tests.py index ec6c213b7..8a7f7b81d 100644 --- a/coldfront/plugins/ldap/tests.py +++ b/coldfront/plugins/ldap/tests.py @@ -5,15 +5,17 @@ from django.test import TestCase, tag from django.contrib.auth import get_user_model -from coldfront.plugins.ldap.utils import (format_template_assertions, - LDAPConn, - GroupUserCollection, - add_new_projects) +from coldfront.plugins.ldap.utils import ( + LDAPConn, + GroupUserCollection, + add_new_projects, + format_template_assertions, +) from coldfront.core.test_helpers.factories import setup_models UTIL_FIXTURES = [ - "coldfront/core/test_helpers/test_data/test_fixtures/ifx.json", + "coldfront/core/test_helpers/test_data/test_fixtures/ifx.json", ] class UtilFunctionTests(TestCase): @@ -84,6 +86,7 @@ def test_return_group_members_manager(self): members, manager = self.ldap_conn.return_group_members_manager(samaccountname) self.assertEqual(len(members), 1) + class GroupUserCollectionTests(TestCase): """Tests for GroupUserCollection class""" fixtures = UTIL_FIXTURES diff --git a/coldfront/plugins/ldap/utils.py b/coldfront/plugins/ldap/utils.py index 8e8652f72..811679f53 100644 --- a/coldfront/plugins/ldap/utils.py +++ b/coldfront/plugins/ldap/utils.py @@ -9,16 +9,24 @@ from django.utils import timezone from django.contrib.auth import get_user_model from ldap3 import Connection, Server, ALL_ATTRIBUTES +from ldap3.extend.microsoft.addMembersToGroups import ad_add_members_to_groups +from ldap3.extend.microsoft.removeMembersFromGroups import ad_remove_members_from_groups from coldfront.core.utils.common import import_from_settings from coldfront.core.field_of_science.models import FieldOfScience -from coldfront.core.utils.fasrc import (id_present_missing_users, - log_missing, slate_for_check, sort_by) -from coldfront.core.project.models import ( Project, - ProjectStatusChoice, - ProjectUserRoleChoice, - ProjectUserStatusChoice, - ProjectUser) +from coldfront.core.utils.fasrc import ( + id_present_missing_users, + log_missing, + slate_for_check, + sort_by, +) +from coldfront.core.project.models import ( + Project, + ProjectStatusChoice, + ProjectUserRoleChoice, + ProjectUserStatusChoice, + ProjectUser, +) logger = logging.getLogger(__name__) @@ -27,14 +35,30 @@ class LDAPConn: """ LDAP connection object """ - def __init__(self): - self.LDAP_SERVER_URI = import_from_settings('AUTH_LDAP_SERVER_URI', None) - self.LDAP_BIND_DN = import_from_settings('AUTH_LDAP_BIND_DN', None) - self.LDAP_BIND_PASSWORD = import_from_settings('AUTH_LDAP_BIND_PASSWORD', None) - self.LDAP_USER_SEARCH_BASE = import_from_settings('AUTH_LDAP_USER_SEARCH_BASE', None) - self.LDAP_GROUP_SEARCH_BASE = import_from_settings('AUTH_LDAP_GROUP_SEARCH_BASE', None) - self.LDAP_CONNECT_TIMEOUT = import_from_settings('LDAP_CONNECT_TIMEOUT', 20) - self.LDAP_USE_SSL = import_from_settings('AUTH_LDAP_USE_SSL', False) + def __init__(self, test=False): + + AUTH_LDAP_SERVER_URI = ( + 'TEST_AUTH_LDAP_SERVER_URI' if test else 'AUTH_LDAP_SERVER_URI') + AUTH_LDAP_BIND_DN = ( + 'TEST_AUTH_LDAP_BIND_DN' if test else 'AUTH_LDAP_BIND_DN') + AUTH_LDAP_BIND_PASSWORD = ( + 'TEST_AUTH_LDAP_BIND_PASSWORD' if test else 'AUTH_LDAP_BIND_PASSWORD') + AUTH_LDAP_USER_SEARCH_BASE = ( + 'TEST_AUTH_LDAP_USER_SEARCH_BASE' if test else 'AUTH_LDAP_USER_SEARCH_BASE') + AUTH_LDAP_GROUP_SEARCH_BASE = ( + 'TEST_AUTH_LDAP_GROUP_SEARCH_BASE' if test else 'AUTH_LDAP_GROUP_SEARCH_BASE') + LDAP_CONNECT_TIMEOUT = ( + 'TEST_LDAP_CONNECT_TIMEOUT' if test else 'LDAP_CONNECT_TIMEOUT') + AUTH_LDAP_USE_SSL = ( + 'TEST_AUTH_LDAP_USE_SSL' if test else 'AUTH_LDAP_USE_SSL') + + self.LDAP_SERVER_URI = import_from_settings(AUTH_LDAP_SERVER_URI, None) + self.LDAP_BIND_DN = import_from_settings(AUTH_LDAP_BIND_DN, None) + self.LDAP_BIND_PASSWORD = import_from_settings(AUTH_LDAP_BIND_PASSWORD, None) + self.LDAP_USER_SEARCH_BASE = import_from_settings(AUTH_LDAP_USER_SEARCH_BASE, None) + self.LDAP_GROUP_SEARCH_BASE = import_from_settings(AUTH_LDAP_GROUP_SEARCH_BASE, None) + self.LDAP_CONNECT_TIMEOUT = import_from_settings(LDAP_CONNECT_TIMEOUT, 20) + self.LDAP_USE_SSL = import_from_settings(AUTH_LDAP_USE_SSL, False) self.server = Server(self.LDAP_SERVER_URI, use_ssl=self.LDAP_USE_SSL, connect_timeout=self.LDAP_CONNECT_TIMEOUT) self.conn = Connection(self.server, self.LDAP_BIND_DN, self.LDAP_BIND_PASSWORD, auto_bind=True) @@ -61,7 +85,6 @@ def search(self, attr_search_dict, search_base, attributes=ALL_ATTRIBUTES): self.conn.search(**search_parameters) return self.conn.entries - def search_users(self, attr_search_dict, attributes=ALL_ATTRIBUTES, return_as='dict'): """search for users. Parameters @@ -74,16 +97,14 @@ def search_users(self, attr_search_dict, attributes=ALL_ATTRIBUTES, return_as='d attributes to return or ldap search objects (e.g., ALL_ATTRIBUTES) return_as : string if 'dict', return entry_attributes_as_dict. Otherwise, return ldap3 entries. - """ - user_entries = self.search( attr_search_dict, - self.LDAP_USER_SEARCH_BASE, - attributes=attributes) + user_entries = self.search( + attr_search_dict, self.LDAP_USER_SEARCH_BASE, attributes=attributes + ) if return_as == 'dict': user_entries = [e.entry_attributes_as_dict for e in user_entries] return user_entries - def search_groups(self, attr_search_dict, attributes=ALL_ATTRIBUTES, return_as='dict'): """search for groups. Parameters @@ -97,9 +118,9 @@ def search_groups(self, attr_search_dict, attributes=ALL_ATTRIBUTES, return_as=' return_as : string if 'dict', return entry_attributes_as_dict. Otherwise, return ldap3 entries. """ - group_entries = self.search(attr_search_dict, - self.LDAP_GROUP_SEARCH_BASE, - attributes=attributes) + group_entries = self.search( + attr_search_dict, self.LDAP_GROUP_SEARCH_BASE, attributes=attributes + ) if return_as == 'dict': group_entries = [e.entry_attributes_as_dict for e in group_entries] return group_entries @@ -108,15 +129,80 @@ def return_multi_group_members_manager(self, samaccountname_list): """return tuples of user and PI entries for each group listed in samaccountname_list """ group_entries = self.search_groups( - {'sAMAccountName': samaccountname_list}, - attributes=['managedBy', 'distinguishedName', - 'sAMAccountName', 'member'] - ) + {'sAMAccountName': samaccountname_list}, + attributes=['managedBy', 'distinguishedName', 'sAMAccountName', 'member'] + ) manager_members_tuples = [] for entry in group_entries: manager_members_tuples.append(self.manager_members_from_group(entry)) return manager_members_tuples + def return_user_by_name(self, username, return_as='dict', attributes=ALL_ATTRIBUTES): + """Return an AD user entry by the username""" + user = self.search_users({"uid": username}, return_as=return_as, attributes=attributes) + if len(user) > 1: + raise ValueError("too many users in value returned") + if not user: + raise ValueError("no users returned") + return user[0] + + def return_group_by_name(self, groupname, return_as='dict'): + group = self.search_groups({"sAMAccountName": groupname}, return_as=return_as) + if len(group) > 1: + raise ValueError("too many groups in value returned") + if not group: + raise ValueError("no groups returned") + return group[0] + + def add_member_to_group(self, user_name, group_name): + # get group + group = self.return_group_by_name(group_name) + # get user + try: + user = self.return_user_by_name(user_name) + except ValueError as e: + raise e + group_dn = group['distinguishedName'] + user_dn = user['distinguishedName'] + try: + result = ad_add_members_to_groups(self.conn, [user_dn], group_dn, fix=True) + except Exception as e: + raise e + return result + + def remove_member_from_group(self, user_name, group_name): + # get group + try: + group = self.return_group_by_name(group_name) + except ValueError as e: + raise e + # get user + try: + user = self.return_user_by_name(user_name) + except ValueError as e: + raise e + if user['gidNumber'] == group['gidNumber']: + raise ValueError("group is user's primary group - please contact FASRC support to remove this user from your group.") + group_dn = group['distinguishedName'] + user_dn = user['distinguishedName'] + try: + result = ad_remove_members_from_groups(self.conn, [user_dn], group_dn, fix=True) + except Exception as e: + raise e + return result + + def users_in_primary_group(self, usernames, groupname): + """ + Return list of usernames representing users that are members of the + designated AD Group + """ + group = self.return_group_by_name(groupname) + attrs = ['sAMAccountName', 'gidNumber'] + users = [self.return_user_by_name(user, attributes=attrs) for user in usernames] + return [ + u['sAMAccountName'][0] for u in users if u['gidNumber'] == group['gidNumber'] + ] + def return_group_members_manager(self, samaccountname): """return user entries that are members of the specified group. @@ -127,10 +213,9 @@ def return_group_members_manager(self, samaccountname): """ logger.debug('return_group_members_manager for Project %s', samaccountname) group_entries = self.search_groups( - {'sAMAccountName': samaccountname}, - attributes=['managedBy', 'distinguishedName', - 'sAMAccountName', 'member'] - ) + {'sAMAccountName': samaccountname}, + attributes=['managedBy', 'distinguishedName','sAMAccountName', 'member'] + ) if len(group_entries) > 1: return 'multiple groups with same sAMAccountName' if not group_entries: @@ -140,9 +225,10 @@ def return_group_members_manager(self, samaccountname): def manager_members_from_group(self, group_entry): group_dn = group_entry['distinguishedName'][0] - user_attr_list = ['sAMAccountName', 'cn', 'name', 'title', 'department', + user_attr_list = [ + 'sAMAccountName', 'cn', 'name', 'title', 'department', 'distinguishedName', 'accountExpires', 'info', 'userAccountControl' - ] + ] group_members = self.search_users({'memberOf': group_dn}, attributes=user_attr_list) logger.debug('group_members:\n%s', group_members) try: @@ -150,8 +236,9 @@ def manager_members_from_group(self, group_entry): except Exception as e: return 'no manager specified' manager_attr_list = user_attr_list + ['memberOf'] - group_manager = self.search_users({'distinguishedName': group_manager_dn}, - attributes=manager_attr_list) + group_manager = self.search_users( + {'distinguishedName': group_manager_dn}, attributes=manager_attr_list + ) logger.debug('group_manager:\n%s', group_manager) if not group_manager: return 'no ADUser manager found' @@ -181,7 +268,6 @@ def pi_is_active(self): """Return true if PI's account is both unexpired and not disabled.""" return user_valid(self.pi) - def compare_active_members_projectusers(self): """Compare ADGroup members to ProjectUsers. @@ -201,8 +287,10 @@ def compare_active_members_projectusers(self): else: logger.warning('WARNING: NO AD USERS RETURNED FOR %s', self.project.title) ad_users = [] - proj_usernames = [pu.user.username for pu in self.project.projectuser_set.filter( - (~Q(status__name='Removed')))] + proj_usernames = [ + pu.user.username for pu in self.project.projectuser_set.filter( + (~Q(status__name='Removed'))) + ] logger.debug('projectusernames: %s', proj_usernames) members_only, _, projuser_only = uniques_and_intersection(ad_users, proj_usernames) @@ -271,6 +359,7 @@ def flatten(l): return [item for sublist in l for item in sublist] + def collect_update_project_status_membership(): """ Update Project and ProjectUser entries for existing Coldfront Projects using @@ -281,11 +370,12 @@ def collect_update_project_status_membership(): projectuserstatus_active = ProjectUserStatusChoice.objects.get(name='Active') projectusers_to_remove = [] - active_projects = Project.objects.filter(status__name__in=['Active', 'New']).prefetch_related('projectuser_set') + active_projects = Project.objects.filter( + status__name__in=['Active', 'New']).prefetch_related('projectuser_set') ad_conn = LDAPConn() - proj_membs_mans = { proj: ad_conn.return_group_members_manager(proj.title) for proj in active_projects} + proj_membs_mans = {p: ad_conn.return_group_members_manager(p.title) for p in active_projects} proj_membs_mans, _ = cleaned_membership_query(proj_membs_mans) groupusercollections = [GroupUserCollection(k.title, v[0], v[1], project=k) for k, v in proj_membs_mans.items()] @@ -296,9 +386,8 @@ def collect_update_project_status_membership(): logger.debug('projects_to_deactivate %s', projects_to_deactivate) if projects_to_deactivate: pis_to_deactivate = ProjectUser.objects.filter( - reduce(operator.or_, - ( Q(project=project) & Q(user=project.pi) - for project in projects_to_deactivate) + reduce(operator.or_, ( + Q(project=p) & Q(user=p.pi) for p in projects_to_deactivate) )) logger.debug('pis_to_deactivate %s', pis_to_deactivate) pis_to_deactivate.update(status=ProjectUserStatusChoice.objects.get(name='Removed')) @@ -330,25 +419,35 @@ def collect_update_project_status_membership(): # handle any AD users not in Coldfront if ad_users_not_added: - logger.debug('ad_users_not_added - ADUsers not in ProjectUsers:\n%s', ad_users_not_added) + logger.debug( + 'ad_users_not_added - ADUsers not in ProjectUsers:\n%s', + ad_users_not_added + ) # find accompanying ifxusers in the system and add as ProjectUsers present_project_ifxusers, missing_users = id_present_missing_users(ad_users_not_added) - logger.debug('present_project_ifxusers - ADUsers who have ifxuser accounts:\n%s', ad_users_not_added) + logger.debug( + 'present_project_ifxusers - ADUsers who have ifxuser accounts:\n%s', + ad_users_not_added + ) log_missing('user', missing_users) # log missing IFXusers # If user is missing because status was changed to 'removed', update status - present_projectusers = group.project.projectuser_set.filter(user__in=present_project_ifxusers) + present_projectusers = group.project.projectuser_set.filter( + user__in=present_project_ifxusers + ) logger.debug('present_users - ADUsers who have ifxuser accounts:\n%s', ad_users_not_added) if present_projectusers: logger.warning('found reactivated ADUsers for project %s: %s', group.project.title, [user.user.username for user in present_projectusers]) - present_projectusers.update(role=projectuser_role_user, - status=projectuserstatus_active) + present_projectusers.update( + role=projectuser_role_user, status=projectuserstatus_active + ) # create new entries for all new ProjectUsers - missing_projectusers = present_project_ifxusers.exclude(id__in=[ - pu.user.pk for pu in present_projectusers]) + missing_projectusers = present_project_ifxusers.exclude( + id__in=[pu.user.pk for pu in present_projectusers] + ) logger.debug("missing_projectusers - ifxusers in present_project_ifxusers who are not ") ProjectUser.objects.bulk_create([ProjectUser( project=group.project, @@ -369,9 +468,9 @@ def collect_update_project_status_membership(): # change ProjectUser status to Removed projectuserstatus_removed = ProjectUserStatusChoice.objects.get(name='Removed') ProjectUser.objects.bulk_update([ - ProjectUser(id=pu.id, status=projectuserstatus_removed) - for pu in projectusers_to_remove - ], ['status']) + ProjectUser(id=pu.id, status=projectuserstatus_removed) + for pu in projectusers_to_remove + ], ['status']) logger.info('changing status of these ProjectUsers to "Removed":%s', [(pu.user.username, pu.project.title) for pu in projectusers_to_remove]) @@ -379,7 +478,13 @@ def import_projects_projectusers(projects_list): """Use AD user and group information to automatically create new Coldfront Projects from projects_list. """ - errortracker = { 'no_pi': [], 'not_found': [], 'no_fos': [], 'pi_not_projectuser': [], 'pi_active_invalid': [] } + errortracker = { + 'no_pi': [], + 'not_found': [], + 'no_fos': [], + 'pi_not_projectuser': [], + 'pi_active_invalid': [] + } # if project already exists, end here existing_projects = Project.objects.filter(title__in=projects_list) if existing_projects: @@ -390,7 +495,9 @@ def import_projects_projectusers(projects_list): proj_membs_mans = {proj: ad_conn.return_group_members_manager(proj) for proj in projects_to_add} proj_membs_mans, search_errors = cleaned_membership_query(proj_membs_mans) errortracker['not_found'] = search_errors - groupusercollections = [GroupUserCollection(k, v[0], v[1]) for k, v in proj_membs_mans.items()] + groupusercollections = [ + GroupUserCollection(k, v[0], v[1]) for k, v in proj_membs_mans.items() + ] added_projects, errortracker = add_new_projects(groupusercollections, errortracker) return added_projects, errortracker @@ -405,32 +512,41 @@ def add_new_projects(groupusercollections, errortracker): logger.debug('active_pi_groups: %s', active_pi_groups) # if PI lacks 'harvard_faculty' or 'non_faculty_pi' Affiliation, don't add pi_groups = ['harvard_faculty', 'non_faculty_pi'] - active_valid_pi_groups = [g for g in active_pi_groups if any(any(string in m for string in pi_groups) for m in g.pi['memberOf'])] + active_valid_pi_groups = [ + g for g in active_pi_groups + if any(any(string in m for string in pi_groups) for m in g.pi['memberOf']) + ] logger.debug('active_invalid_pi_groups: %s', set(active_valid_pi_groups) - set(active_pi_groups)) errortracker['pi_active_invalid'] = [group.name for group in active_pi_groups if group not in active_valid_pi_groups] # identify all users not in ifx - user_entries = flatten([group.members + [group.pi] for group in active_valid_pi_groups]) + user_entries = flatten([g.members + [g.pi] for g in active_valid_pi_groups]) user_names = {u['sAMAccountName'][0] for u in user_entries} _, missing_users = id_present_missing_users(user_names) missing_usernames = {d['username'] for d in missing_users} - active_present_pi_groups = [group for group in active_valid_pi_groups - if group.pi['sAMAccountName'][0] not in missing_usernames] - missing_pi_groups = [group for group in groupusercollections if group not in active_present_pi_groups] - missing_pis = [{'username': g.pi['sAMAccountName'][0], 'group': g.name} for g in missing_pi_groups] + active_present_pi_groups = [ + g for g in active_valid_pi_groups if g.pi['sAMAccountName'][0] not in missing_usernames + ] + missing_pi_groups = [g for g in groupusercollections if g not in active_present_pi_groups] + missing_pis = [ + {'username': g.pi['sAMAccountName'][0], 'group': g.name} for g in missing_pi_groups + ] log_missing('user', missing_pis) # record and remove projects where pis aren't available - errortracker['no_pi'] = [group.name for group in groupusercollections - if group not in active_present_pi_groups] + errortracker['no_pi'] = [ + g.name for g in groupusercollections if g not in active_present_pi_groups + ] added_projects = [] for group in active_present_pi_groups: logger.debug('source: %s\n%s\n%s', group.name, group.members, group.pi) # collect group membership entries member_usernames = {u['sAMAccountName'][0] for u in group.current_ad_users} - missing_usernames - missing_members = [{'username': m['sAMAccountName'][0], 'group': group.name} for m in group.members] + missing_members = [ + {'username': m['sAMAccountName'][0], 'group': group.name} for m in group.members + ] log_missing('user', missing_members) # locate field_of_science @@ -438,9 +554,8 @@ def add_new_projects(groupusercollections, errortracker): field_of_science_name=group.pi['department'][0] logger.debug('field_of_science_name %s', field_of_science_name) field_of_science_obj, created = FieldOfScience.objects.get_or_create( - description=field_of_science_name, - defaults={'is_selectable':'True'} - ) + description=field_of_science_name, defaults={'is_selectable':'True'} + ) if created: logger.info('added new field_of_science: %s', field_of_science_name) else: @@ -448,10 +563,11 @@ def add_new_projects(groupusercollections, errortracker): message = f'no department for AD group {group.name}, will not add unless fixed' logger.warning(message) print(f'HALTING: {message}') - issue = {'error': message, - 'program': 'ldap.utils.add_new_projects', - 'url': 'NA; AD issue' - } + issue = { + 'error': message, + 'program': 'ldap.utils.add_new_projects', + 'url': 'NA; AD issue', + } slate_for_check([issue]) print(group.pi) continue @@ -475,13 +591,13 @@ def add_new_projects(groupusercollections, errortracker): users_to_add = get_user_model().objects.filter(username__in=member_usernames) added_projectusers = ProjectUser.objects.bulk_create([ ProjectUser( - project=group.project, - user=user, - status=ProjectUserStatusChoice.objects.get(name='Active'), - role=ProjectUserRoleChoice.objects.get(name='User'), - ) + project=group.project, + user=user, + status=ProjectUserStatusChoice.objects.get(name='Active'), + role=ProjectUserRoleChoice.objects.get(name='User'), + ) for user in users_to_add - ]) + ]) logger.debug('added projectusers: %s', added_projectusers) # add permissions to PI/manager-status ProjectUsers logger.debug('adding manager status to ProjectUser %s for Project %s',