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',