diff --git a/app/constants.py b/app/constants.py index c6a66a80..6237729c 100644 --- a/app/constants.py +++ b/app/constants.py @@ -1,5 +1,5 @@ -global_admin = "globalAdmin" -admin_project = "adminProject" -practice_lead_project = "practiceLeadProject" -member_project = "memberProject" -self_value = "self" +ADMIN_GLOBAL = "adminGlobal" +ADMIN_PROJECT = "adminProject" +PRACTICE_LEAD_PROJECT = "practiceLeadProject" +MEMBER_PROJECT = "memberProject" +FIELD_PERMISSIONS_CSV = "core/api/field_permissions.csv" diff --git a/app/core/api/access_control.py b/app/core/api/access_control.py new file mode 100644 index 00000000..942796b8 --- /dev/null +++ b/app/core/api/access_control.py @@ -0,0 +1,177 @@ +import csv +from pathlib import Path +from typing import Any + +from constants import ADMIN_GLOBAL # Assuming you have this constant +from constants import FIELD_PERMISSIONS_CSV +from core.models import PermissionType +from core.models import UserPermission + + +class AccessControl: + """A collection of static methods for validating user permissions.""" + + _rank_dict_cache: dict[str, int] | None = None # class-level cache + _csv_field_permissions_cache: list[dict[str, Any]] | None = None + + @staticmethod + def is_admin(user) -> bool: + """Check if a user assigned "adminGlobal" permission.""" + permission_type = PermissionType.objects.filter(name=ADMIN_GLOBAL).first() + # return True + return UserPermission.objects.filter( + permission_type=permission_type, user=user + ).exists() + + @classmethod + def _get_rank_dict(cls) -> dict[str, int]: + """Return a dictionary mapping permission names to their ranks. + Example: {"adminGlobal": 1, "adminProject": 2, "practiceLeadProject": 3, "memberProject": 4}. + Used in algorithm to determine most privileged permission type between two users. The higher the rank, + the more privileged the permission. + """ + if cls._rank_dict_cache is None: + permissions = PermissionType.objects.values("name", "rank") + cls._rank_dict_cache = {perm["name"]: perm["rank"] for perm in permissions} + return cls._rank_dict_cache + + @classmethod + def _get_csv_field_permissions(cls) -> list[dict[str, Any]]: + """Read the field permissions from a CSV file. + + Caches the result so the CSV is read only once. + """ + if cls._csv_field_permissions_cache is None: + file_path = Path(FIELD_PERMISSIONS_CSV) + with file_path.open() as file: + reader = csv.DictReader(file) + cls._csv_field_permissions_cache = list(reader) + return cls._csv_field_permissions_cache + + @classmethod + def get_permitted_fields( + cls, operation: str, permission_type: str, table_name: str + ) -> list[str]: + """ + Return the list of field names accessible for a user with the given permission type + for a given operation. + + Parameters: + operation (str): The type of operation. (e.g., "get", "post", "patch"). + permission_type (str): The permission type of the requesting user + (e.g., "adminGlobal", "adminProject", etc.). + table_name (str): The name of the table/model + (e.g., "User", "Project"). + + Returns: + list[str]: A list of field names that the user with the given + permission_type can access for the specified operation on the + specified table. + + Example: + >>> get_fields("get", "adminProject", "User") + ["first_name", "last_name"] + """ + if not permission_type: # Early exit guard clause + return [] + + valid_fields = set() + + for field_permission in cls._get_csv_field_permissions(): + if cls.has_field_permission( + operation=operation, + requester_permission_type=permission_type, + table_name=table_name, + field=field_permission, + ): + valid_fields.add(field_permission["field_name"]) + + return list(valid_fields) + + @classmethod + def get_highest_user_perm_type(cls, requesting_user) -> str: + """Return the most privileged permission type of a user.""" + + permissions = UserPermission.objects.filter( + user=requesting_user, project__name=None + ).values("permission_type__name", "permission_type__rank") + + if not permissions: + return "" + + max_permission = max(permissions, key=lambda p: p["permission_type__rank"]) + return max_permission["permission_type__name"] + + @classmethod + def get_highest_shared_project_perm_type( + cls, requesting_user, response_related_user + ) -> str: + """Return the most privileged permission type between users.""" + if cls.is_admin(requesting_user): + return ADMIN_GLOBAL + + target_projects = UserPermission.objects.filter( + user=response_related_user + ).values_list("project__name", flat=True) + target_projects = UserPermission.objects.filter( + user=response_related_user + ).values_list("project__name", flat=True) + + permissions = UserPermission.objects.filter( + user=requesting_user, project__name__in=target_projects + ).values("permission_type__name", "permission_type__rank") + if not permissions: + return "" + + max_permission = max(permissions, key=lambda p: p["permission_type__rank"]) + return max_permission["permission_type__name"] + + @classmethod + def has_field_permission( + cls, + operation: str, + requester_permission_type: str, + table_name: str, + field: dict, + ) -> bool: + """ + Determine whether a user with a given permission type has access to a field + for a specific operation on a specific table. + + Parameters: + operation (str): The type of operation ("get", "post", or "patch"). + permission_type (str): The user's permission type + (e.g., "adminGlobal", "adminProject"). + table_name (str): The name of the table/model to check (e.g., "User"). + field (dict): A dictionary describing the field, including at least: + - "field_name" + - "table_name" + - operation-specific permission values + (e.g., {"get": "adminProject"}). + + Returns: + bool: True if the permission type allows access to the field for the operation, + False otherwise. + + Example: + >>> field_info = { + ... "field_name": "email", + ... "table_name": "User", + ... "get": "adminProject" + ... } + >>> has_field_permission("get", "adminProject", "User", field_info) + True + """ + operation_permission_type = field.get(operation, "") + if not operation_permission_type or field.get("table_name") != table_name: + return False + + rank_dict = cls._get_rank_dict() + if ( + requester_permission_type not in rank_dict + or operation_permission_type not in rank_dict + ): + return False + return ( + rank_dict[requester_permission_type] <= rank_dict[operation_permission_type] + ) diff --git a/app/core/api/field_permissions.csv b/app/core/api/field_permissions.csv new file mode 100644 index 00000000..ebfb130e --- /dev/null +++ b/app/core/api/field_permissions.csv @@ -0,0 +1,29 @@ +table_name,field_name,get,patch,post +User,uuid,memberProject,, +User,username,memberProject,,adminGlobal +User,is_active,,, +User,is_superuser,,, +User,is_staff,,, +User,first_name,memberProject,adminBrigade,adminGlobal +User,last_name,memberProject,adminBrigade,adminGlobal +User,email,memberProject,adminBrigade,adminGlobal +User,email_gmail,practiceLeadProject,adminBrigade,adminGlobal +User,email_preferred,practiceLeadProject,adminBrigade,adminGlobal +User,email_cognito,adminBrigade,adminBrigade,adminGlobal +User,created_at,adminProject,,, +User,job_title_current_intake,adminBrigade,adminBrigade,adminGlobal +User,job_title_target_intake,adminBrigade,adminBrigade,adminGlobal +User,current_skills,adminBrigade,adminBrigade,adminGlobal +User,target_skills,adminBrigade,adminBrigade,adminGlobal +User,linkedin_account,memberProject,adminBrigade,adminGlobal +User,github_handle,memberProject,adminBrigade,adminGlobal +User,phone,practiceLeadProject,adminBrigade,adminGlobal +User,texting_ok,practiceLeadProject,adminBrigade,adminGlobal +User,slack_id,memberProject,adminBrigade,adminGlobal +User,time_zone,memberProject,adminBrigade,adminGlobal +User,password,,adminBrigade,adminGlobal +User,last_login,adminProject,adminProject,, +User,practice_area_primary,adminProject,adminGlobal,adminGlobal +User,user_status,memberProject,adminBrigade,adminGlobal +User,updated_at,adminProject,, +User,referrer,memberProject,adminGlobal,adminGlobal diff --git a/app/core/api/has_user_permissions.py b/app/core/api/has_user_permissions.py new file mode 100644 index 00000000..b186aeae --- /dev/null +++ b/app/core/api/has_user_permissions.py @@ -0,0 +1,16 @@ +from rest_framework.permissions import BasePermission + +from .validate_request import validate_patch_fields +from .validate_request import validate_post_fields + + +class HasUserPermission(BasePermission): + def has_permission(self, request, view): + if request.method == "POST": + validate_post_fields(request=request, view=view) + return True # Default to allow the request + + def has_object_permission(self, request, view, obj): + if request.method == "PATCH": + validate_patch_fields(obj=obj, request=request) + return True diff --git a/app/core/api/validate_request.py b/app/core/api/validate_request.py new file mode 100644 index 00000000..9740cff5 --- /dev/null +++ b/app/core/api/validate_request.py @@ -0,0 +1,72 @@ +from rest_framework.exceptions import PermissionDenied +from rest_framework.exceptions import ValidationError + +from core.models import User + +from .access_control import AccessControl + + +def validate_post_fields(view, request): + # todo + serializer_class = view.serializer_class + table_name = serializer_class.Meta.model.__name__ + permitted_fields = _get_permitted_fields_for_post_request( + request=request, table_name=table_name + ) + _validate_request_fields_permitted(request, permitted_fields) + + +def get_fields_for_patch_request(request, table_name, response_related_user): + requesting_user = request.user + requesting_user = request.user + most_privileged_perm_type = AccessControl.get_highest_shared_project_perm_type( + requesting_user, response_related_user + ) + fields = AccessControl.get_permitted_fields( + operation="patch", + table_name=table_name, + permission_type=most_privileged_perm_type, + ) + return fields + + +def _get_permitted_fields_for_post_request(request, table_name): + highest_perm_type = AccessControl.get_highest_user_perm_type(request.user) + fields = AccessControl.get_permitted_fields( + operation="post", + table_name=table_name, + permission_type=highest_perm_type, + ) + return fields + + +def _get_related_user_from_obj(obj): + if hasattr(obj, "user"): + return obj.user + elif isinstance(obj, User): + return obj + else: + raise ValueError("Cannot determine related user from the given object.") + + +def validate_patch_fields(request, obj): + table_name = obj.__class__.__name__ + response_related_user = _get_related_user_from_obj(obj) + valid_fields = get_fields_for_patch_request( + table_name=table_name, + request=request, + response_related_user=response_related_user, + ) + _validate_request_fields_permitted(request, valid_fields) + + +# @staticmethod +def _validate_request_fields_permitted(request, valid_fields) -> None: + """Ensure the requesting user can patch the provided fields.""" + request_fields_set = set(request.data) + permitted_fields_set = set(valid_fields) + notpermitted_fields = request_fields_set - permitted_fields_set + if not permitted_fields_set: + raise PermissionDenied("You do not have privileges ") + elif notpermitted_fields: + raise ValidationError(f"Invalid fields: {', '.join(notpermitted_fields)}") diff --git a/app/core/api/views.py b/app/core/api/views.py index 689ce771..cb5d2159 100644 --- a/app/core/api/views.py +++ b/app/core/api/views.py @@ -11,6 +11,8 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticatedOrReadOnly +from core.api.has_user_permissions import HasUserPermission + from ..models import Affiliate from ..models import Affiliation from ..models import CheckType @@ -123,7 +125,7 @@ def get(self, request, *args, **kwargs): partial_update=extend_schema(description="Partially update the given user"), ) class UserViewSet(viewsets.ModelViewSet): - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, HasUserPermission] serializer_class = UserSerializer lookup_field = "uuid" diff --git a/app/core/tests/conftest.py b/app/core/tests/conftest.py index 667763d2..e6e0f7c0 100644 --- a/app/core/tests/conftest.py +++ b/app/core/tests/conftest.py @@ -1,8 +1,11 @@ import pytest +from django.contrib.auth import get_user_model from rest_framework.test import APIClient -from constants import admin_project -from constants import practice_lead_project +from constants import ADMIN_PROJECT +from constants import PRACTICE_LEAD_PROJECT +from test_data.utils.seed_constants import garry_name +from test_data.utils.seed_user import SeedUser from ..models import Affiliate from ..models import Affiliation @@ -28,10 +31,15 @@ from ..models import StackElement from ..models import StackElementType from ..models import UrlType -from ..models import User from ..models import UserPermission from ..models import UserStatusType +collect_ignore = ["utils"] + +# conftest.py + +User = get_user_model() + @pytest.fixture def user_superuser_admin(): @@ -71,7 +79,7 @@ def user_permission_admin_project(): username="TestUser Admin Project", email="TestUserAdminProject@example.com" ) project = Project.objects.create(name="Test Project Admin Project") - permission_type = PermissionType.objects.filter(name=admin_project).first() + permission_type = PermissionType.objects.filter(name=ADMIN_PROJECT).first() user_permission = UserPermission.objects.create( user=user, permission_type=permission_type, @@ -87,7 +95,7 @@ def user_permission_practice_lead_project(): username="TestUser Practie Lead Project", email="TestUserPracticeLeadProject@example.com", ) - permission_type = PermissionType.objects.filter(name=practice_lead_project).first() + permission_type = PermissionType.objects.filter(name=PRACTICE_LEAD_PROJECT).first() project = Project.objects.create(name="Test Project Admin Project") practice_area = PracticeArea.objects.first() user_permission = UserPermission.objects.create( @@ -120,12 +128,7 @@ def user2(django_user_model): @pytest.fixture def admin(django_user_model): - return django_user_model.objects.create_user( - is_staff=True, - username="TestAdminUser", - email="testadmin@email.com", - password="testadmin", - ) + return SeedUser.get_user(garry_name) @pytest.fixture diff --git a/app/core/tests/test_api.py b/app/core/tests/test_api.py index 520793a0..c13d9114 100644 --- a/app/core/tests/test_api.py +++ b/app/core/tests/test_api.py @@ -5,6 +5,7 @@ from core.api.serializers import ProgramAreaSerializer from core.api.serializers import UserSerializer from core.models import ProgramArea +from core.models import User from core.models import UserPermission pytestmark = pytest.mark.django_db @@ -82,7 +83,7 @@ def test_get_users(auth_client, django_user_model): res = auth_client.get(USERS_URL) assert res.status_code == status.HTTP_200_OK - assert len(res.data) == 3 + assert len(res.data) == User.objects.count() users = django_user_model.objects.all().order_by("created_at") serializer = UserSerializer(users, many=True) @@ -98,44 +99,52 @@ def test_get_single_user(auth_client, user): user_actions_test_data = [ - ( - "admin_client", - "post", - "users_url", - CREATE_USER_PAYLOAD, - status.HTTP_201_CREATED, - ), - ("admin_client", "get", "users_url", {}, status.HTTP_200_OK), - ( - "auth_client", - "post", - "users_url", - CREATE_USER_PAYLOAD, - status.HTTP_201_CREATED, - ), + # Replaced by tests in test_post_users.py + # ( + # "admin_client", + # "post", + # "users_url", + # CREATE_USER_PAYLOAD, + # status.HTTP_201_CREATED, + # ), + # + # Redundant + # + # ("admin_client", "get", "users_url", {}, status.HTTP_200_OK), + # ( + # "auth_client", + # "post", + # "users_url", + # CREATE_USER_PAYLOAD, + # status.HTTP_201_CREATED, + # ), ("auth_client", "get", "users_url", {}, status.HTTP_200_OK), - ( - "auth_client", - "patch", - "user_url", - {"first_name": "TestUser2"}, - status.HTTP_200_OK, - ), - ( - "auth_client", - "put", - "user_url", - CREATE_USER_PAYLOAD, - status.HTTP_200_OK, - ), - ("auth_client", "delete", "user_url", {}, status.HTTP_204_NO_CONTENT), - ( - "admin_client", - "patch", - "user_url", - {"first_name": "TestUser2"}, - status.HTTP_200_OK, - ), + # Replaced by tests in test_patch_users.py + # ( + # "auth_client", + # "patch", + # "user_url", + # {"first_name": "TestUser2"}, + # status.HTTP_200_OK, + # ), + # + # + # ( + # "auth_client", + # "put", + # "user_url", + # CREATE_USER_PAYLOAD, + # status.HTTP_200_OK, + # ), + ("admin_client", "delete", "user_url", {}, status.HTTP_204_NO_CONTENT), + # Replaced by tests in test_patch_users.py + # ( + # "admin_client", + # "patch", + # "user_url", + # {"first_name": "TestUser2"}, + # status.HTTP_200_OK, + # ), ( "admin_client", "put", @@ -144,13 +153,14 @@ def test_get_single_user(auth_client, user): status.HTTP_200_OK, ), ("admin_client", "delete", "user_url", {}, status.HTTP_204_NO_CONTENT), - ( - "auth_client2", - "patch", - "user_url", - {"first_name": "TestUser2"}, - status.HTTP_200_OK, - ), + # Replaced by tests in test_patch_users.py + # ( + # "auth_client2", + # "patch", + # "user_url", + # {"first_name": "TestUser2"}, + # status.HTTP_200_OK, + # ), ( "auth_client2", "put", @@ -271,10 +281,15 @@ def test_project_leadership_type_relationship(auth_client, project_1, leadership reverse("project-detail", args=[project_1.pk]), {"leadership_type": leadership_type.pk}, ) - assert res.status_code == status.HTTP_200_OK + assert res.status_code == status.HTTP_200_OK, res.data res = auth_client.get(PROJECTS_URL) - assert res.data[0]["leadership_type"] == leadership_type.pk + project_from_response = None + for project in res.data: + if project["uuid"] == str(project_1.uuid): + project_from_response = project + break + assert project_from_response["leadership_type"] == leadership_type.pk def test_create_location(auth_client): @@ -560,15 +575,6 @@ def test_create_referrer(auth_client, referrer_type): assert res.data["contact_name"] == payload["contact_name"] -def test_assign_referrer_to_user(auth_client, user, referrer): - payload = {"referrer": str(referrer.uuid)} - - res = auth_client.patch(f"{USERS_URL}{user.uuid}/", payload) - - assert res.status_code == status.HTTP_200_OK - assert str(res.data["referrer"]) == str(referrer.uuid) - - def test_create_project_url(auth_client, project, url_type): payload = { "project": project.pk, diff --git a/app/core/tests/test_field_permisions_csv.py b/app/core/tests/test_field_permisions_csv.py new file mode 100644 index 00000000..483e3753 --- /dev/null +++ b/app/core/tests/test_field_permisions_csv.py @@ -0,0 +1,49 @@ +import pytest +from django.apps import apps + +from core.api.access_control import AccessControl + + +def _parse_csv_permissions(): + table_to_fields = {} + for row in AccessControl._get_csv_field_permissions(): + table = row["table_name"] + field = row["field_name"] + table_to_fields.setdefault(table, set()).add(field) + return table_to_fields + + +def _compare_model_to_csv(table_name, csv_fields): + try: + model = apps.get_model(app_label="core", model_name=table_name) + except LookupError: + return [f"Model not found for table '{table_name}'"] + + model_fields = { + f.name + for f in model._meta.get_fields() + if not (f.many_to_many or f.one_to_many) + } + missing_in_csv = model_fields - csv_fields + extra_in_csv = csv_fields - model_fields + + if missing_in_csv or extra_in_csv: + return [ + ( + f"Table '{table_name}' mismatch:\n" + f" Missing in CSV: {sorted(missing_in_csv)}\n" + f" Extra in CSV: {sorted(extra_in_csv)}" + ) + ] + return [] + + +@pytest.mark.django_db +def test_model_fields_match_permissions_csv(): + table_to_fields = _parse_csv_permissions() + errors = [] + for table_name, csv_fields in table_to_fields.items(): + errors.extend(_compare_model_to_csv(table_name, csv_fields)) + + print("Errors:", errors) + assert not errors, "\n\n".join(errors) diff --git a/app/core/tests/test_models.py b/app/core/tests/test_models.py index 59e64d4e..2ac4eb93 100644 --- a/app/core/tests/test_models.py +++ b/app/core/tests/test_models.py @@ -17,7 +17,7 @@ def test_user(user, django_user_model): - assert django_user_model.objects.filter(is_staff=False).count() == 1 + assert django_user_model.objects.filter(email="testuser@email.com").count() == 1 assert str(user) == "testuser@email.com" assert user.is_django_user is True assert repr(user) == f"" diff --git a/app/core/tests/test_patch_users.py b/app/core/tests/test_patch_users.py new file mode 100644 index 00000000..aebda2d6 --- /dev/null +++ b/app/core/tests/test_patch_users.py @@ -0,0 +1,111 @@ +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from test_data.utils.seed_constants import garry_name +from test_data.utils.seed_constants import valerie_name +from test_data.utils.seed_constants import wanda_admin_project +from test_data.utils.seed_user import SeedUser + + +def _call_api(requesting_user_name, response_related_name, data): + requester = SeedUser.get_user(requesting_user_name) + client = APIClient() + client.force_authenticate(user=requester) + + response_related_user = SeedUser.get_user(response_related_name) + url = reverse("user-detail", args=[response_related_user.uuid]) + data = data + return client.patch(url, data, format="json") + + +@pytest.mark.django_db +class TestPatchUser: + def test_patch_request_calls_validate_request(self, mocker): + requester = SeedUser.get_user(garry_name) + client = APIClient() + client.force_authenticate(user=requester) + response_related_user = SeedUser.get_user(valerie_name) + url = reverse("user-detail", args=[response_related_user.uuid]) + data = { + "last_name": "Updated", + "email_gmail": "update@example.com", + } + + mock_validate_patch = mocker.patch( + "core.api.has_user_permissions.validate_patch_fields" + ) + client.patch(url, data, format="json") + + # Assert it was called + mock_validate_patch.assert_called_once() + __args__, kwargs = mock_validate_patch.call_args + request_received = kwargs.get("request") + response_related_user_received = kwargs.get("obj") + + assert request_received.data == data + assert request_received.user == requester + assert response_related_user_received == response_related_user + + def test_valid_patch(self): + patch_data = { + "last_name": "Foo", + # "email_gmail": "smith@example.com", + # "first_name": "John", + } + response = _call_api( + requesting_user_name=garry_name, + response_related_name=wanda_admin_project, + data=patch_data, + ) + assert response.status_code == status.HTTP_200_OK, response.data + + def test_patch_with_not_permitted_fields(self): + """Test patch request returns 400 response when request fields do not match configured fields. + + Fields are configured to not include last_name. The test will attempt to create a user + with last_name in the request data. The test should fail with a 400 status code. + + See documentation for test_allowable_patch_fields_configurable for more information. + """ + + patch_data = { + "email_gmail": "smith@example.com", + "created_at": "2022-01-01T00:00:00Z", + } + response = _call_api( + requesting_user_name=garry_name, + response_related_name=wanda_admin_project, + data=patch_data, + ) + response = _call_api( + requesting_user_name=garry_name, + response_related_name=wanda_admin_project, + data=patch_data, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_patch_with_unprivileged_requesting_user(self): + """Test patch request returns 400 response when request fields do not match configured fields. + + Fields are configured to not include last_name. The test will attempt to create a user + with last_name in the request data. The test should fail with a 400 status code. + + See documentation for test_allowable_patch_fields_configurable for more information. + """ + + patch_data = { + "email_gmail": "smith@example.com", + } + response = _call_api( + requesting_user_name=wanda_admin_project, + response_related_name=valerie_name, + data=patch_data, + ) + response = _call_api( + requesting_user_name=wanda_admin_project, + response_related_name=valerie_name, + data=patch_data, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/app/core/tests/test_post_users.py b/app/core/tests/test_post_users.py new file mode 100644 index 00000000..5a088684 --- /dev/null +++ b/app/core/tests/test_post_users.py @@ -0,0 +1,92 @@ +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIRequestFactory +from rest_framework.test import force_authenticate + +from core.api.views import UserViewSet +from test_data.utils.seed_constants import garry_name +from test_data.utils.seed_constants import wanda_admin_project +from test_data.utils.seed_user import SeedUser + +count_website_members = 4 +count_people_depot_members = 3 +count_members_either = 6 + + +@pytest.mark.django_db +class TestPostUser: + @staticmethod + def _post_request_to_viewset(requesting_user, create_data): + new_data = create_data.copy() + factory = APIRequestFactory() + request = factory.post(reverse("user-list"), data=new_data, format="json") + force_authenticate(request, user=requesting_user) + view = UserViewSet.as_view({"post": "create"}) + response = view(request) + return response + + @classmethod + def test_valid_post(cls): + """Test POST request returns success when the request fields match configured fields. + + This test calls the UserViewSet directly with the request. + """ + requesting_user = SeedUser.get_user(garry_name) # project lead for website + + create_data = { + "username": "foo", + "last_name": "Smith", + "first_name": "John", + "email_gmail": "smith@example.com", + "time_zone": "America/Los_Angeles", + } + response = cls._post_request_to_viewset(requesting_user, create_data) + + assert response.status_code == status.HTTP_201_CREATED, response.data + + def test_post_with_not_permitted_fields(self): + """Test post request returns 400 response when request fields do not match configured fields. + + Fields are configured to not include last_name. The test will attempt to create a user + with last_name in the request data. The test should fail with a 400 status code. + + See documentation for test_allowable_patch_fields_configurable for more information. + """ + + requesting_user = SeedUser.get_user(garry_name) # project lead for website + post_data = { + "username": "foo", + "first_name": "Mary", + "last_name": "Smith", + "email_gmail": "smith@example.com", + "time_zone": "America/Los_Angeles", + "password": "password", + "created_at": "2022-01-01T00:00:00Z", + } + response = TestPostUser._post_request_to_viewset(requesting_user, post_data) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_post_with_unprivileged_requesting_user(self): + """Test post request returns 400 response when request fields do not match configured fields. + + Fields are configured to not include last_name. The test will attempt to create a user + with last_name in the request data. The test should fail with a 400 status code. + + See documentation for test_allowable_patch_fields_configurable for more information. + """ + + requesting_user = SeedUser.get_user( + wanda_admin_project + ) # project lead for website + post_data = { + "username": "foo", + "first_name": "Mary", + "last_name": "Smith", + "email_gmail": "smith@example.com", + "time_zone": "America/Los_Angeles", + "password": "password", + "created_at": "2022-01-01T00:00:00Z", + } + response = TestPostUser._post_request_to_viewset(requesting_user, post_data) + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/app/core/tests/test_unit_access_control.py b/app/core/tests/test_unit_access_control.py new file mode 100644 index 00000000..0716e963 --- /dev/null +++ b/app/core/tests/test_unit_access_control.py @@ -0,0 +1,68 @@ +import pytest + +from constants import ADMIN_GLOBAL +from constants import ADMIN_PROJECT +from constants import MEMBER_PROJECT +from core.api.access_control import AccessControl + +# from core.api.user_related_request import UserRelatedRequest +from test_data.utils.seed_constants import garry_name +from test_data.utils.seed_constants import patti_name +from test_data.utils.seed_constants import wally_name +from test_data.utils.seed_constants import wanda_admin_project +from test_data.utils.seed_constants import zani_name +from test_data.utils.seed_user import SeedUser + + +@pytest.mark.django_db +def test_is_admin(): + """Test that is_admin returns True for an admin user.""" + admin_user = SeedUser.get_user(garry_name) + + assert AccessControl.is_admin(admin_user) is True + + +@pytest.mark.django_db +def test_is_not_admin(): + """Test that is_admin returns True for an admin user.""" + admin_user = SeedUser.get_user(wanda_admin_project) + assert AccessControl.is_admin(admin_user) is False + + +@pytest.mark.parametrize( # noqa: PT006 PT007 + "request_user_name, response_related_user_name, expected_permission_type", + ( + # Wanda is an admin project for website, Wally is on the same project => ADMIN_PROJECT + (wanda_admin_project, wally_name, ADMIN_PROJECT), + # Wally is a project member for website, Wanda is on the same project => MEMBER_PROJECT + (wally_name, wanda_admin_project, MEMBER_PROJECT), + # Garry is both a project admin for website and a global admin => ADMIN_GLOBAL + (garry_name, wally_name, ADMIN_GLOBAL), + # Wally is a project member of website and Garry is a project lead on the same team + # => MEMBER_PROJECT + (wally_name, garry_name, MEMBER_PROJECT), + # Garry is a global admin. Even though Patti is not assigned to same team => ADMIN_GLOBAL + (garry_name, patti_name, ADMIN_GLOBAL), + # Patti has no project in common with Garry => "" + (patti_name, wally_name, ""), + # Zani is part of two projects with different permission types + # Zani is a MEMBER_PROJECT for website, Wally is assigned same team => MEMBER_PROJECT + (zani_name, wally_name, MEMBER_PROJECT), + # Zani is a project admin for website, Wally is assigned same team => ADMIN_PROJECT + (zani_name, patti_name, ADMIN_PROJECT), + ), +) +@pytest.mark.django_db +# see load_user_data_required in conftest.py +def test_get_highest_shared_project_perm_type( + request_user_name, response_related_user_name, expected_permission_type +): + """Test that the correct permission type is returned.""" + request_user = SeedUser.get_user(request_user_name) + response_related_user = SeedUser.get_user(response_related_user_name) + assert ( + AccessControl.get_highest_shared_project_perm_type( + request_user, response_related_user + ) + == expected_permission_type + ) diff --git a/app/core/tests/utils/__init__.py b/app/core/tests/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/peopledepot/settings.py b/app/peopledepot/settings.py index 27fb1879..5fdbc653 100644 --- a/app/peopledepot/settings.py +++ b/app/peopledepot/settings.py @@ -88,6 +88,7 @@ # Local "core", "data", + "test_data", ] MIDDLEWARE = [ diff --git a/app/requirements.in b/app/requirements.in index de318866..0d990ebd 100644 --- a/app/requirements.in +++ b/app/requirements.in @@ -1,3 +1,4 @@ +bandit django~=4.2.0 django-extensions django-linear-migrations @@ -10,5 +11,6 @@ markdown psycopg2-binary pytest-cov pytest-django +pytest-mock pytest-xdist tzdata diff --git a/app/requirements.txt b/app/requirements.txt index 03078097..54ff0803 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -1,3 +1,9 @@ +# +# This file is autogenerated by pip-compile with Python 3.9 +# by the following command: +# +# pip-compile requirements.in +# asgiref==3.8.1 # via django attrs==24.2.0 @@ -6,12 +12,13 @@ attrs==24.2.0 # referencing cffi==1.17.1 # via cryptography -coverage==7.6.8 +coverage[toml]==7.6.8 # via pytest-cov cryptography==44.0.0 # via pyjwt django==4.2.16 # via + # -r requirements.in # django-extensions # django-linear-migrations # django-phonenumber-field @@ -20,19 +27,28 @@ django==4.2.16 # drf-jwt # drf-spectacular django-extensions==3.2.3 + # via -r requirements.in django-linear-migrations==2.16.0 -django-phonenumber-field==8.0.0 + # via -r requirements.in +django-phonenumber-field[phonenumbers]==8.0.0 + # via -r requirements.in django-timezone-field==7.0 + # via -r requirements.in djangorestframework==3.15.2 # via + # -r requirements.in # drf-jwt # drf-spectacular drf-jwt==1.19.2 + # via -r requirements.in drf-spectacular==0.28.0 + # via -r requirements.in exceptiongroup==1.2.2 # via pytest execnet==2.1.1 # via pytest-xdist +importlib-metadata==8.7.0 + # via markdown inflection==0.5.1 # via drf-spectacular iniconfig==2.0.0 @@ -42,6 +58,7 @@ jsonschema==4.23.0 jsonschema-specifications==2024.10.1 # via jsonschema markdown==3.7 + # via -r requirements.in packaging==24.2 # via pytest phonenumbers==8.13.51 @@ -49,18 +66,27 @@ phonenumbers==8.13.51 pluggy==1.5.0 # via pytest psycopg2-binary==2.9.10 + # via -r requirements.in pycparser==2.22 # via cffi -pyjwt==2.10.1 - # via drf-jwt +pyjwt[crypto]==2.10.1 + # via + # drf-jwt + # pyjwt pytest==8.3.4 # via # pytest-cov # pytest-django + # pytest-mock # pytest-xdist pytest-cov==6.0.0 + # via -r requirements.in pytest-django==4.9.0 + # via -r requirements.in +pytest-mock==3.15.1 + # via -r requirements.in pytest-xdist==3.6.1 + # via -r requirements.in pyyaml==6.0.2 # via drf-spectacular referencing==0.35.1 @@ -78,7 +104,12 @@ tomli==2.2.1 # coverage # pytest typing-extensions==4.12.2 - # via asgiref + # via + # asgiref + # drf-spectacular tzdata==2024.2 + # via -r requirements.in uritemplate==4.1.1 # via drf-spectacular +zipp==3.23.0 + # via importlib-metadata diff --git a/app/test_data/__init__.py b/app/test_data/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/test_data/migrations/0001_populate_test_user_permission.py b/app/test_data/migrations/0001_populate_test_user_permission.py new file mode 100644 index 00000000..d6bef4a3 --- /dev/null +++ b/app/test_data/migrations/0001_populate_test_user_permission.py @@ -0,0 +1,21 @@ +# app/test_data/migrations/0001_populate_test_user_permission.py +from django.db import migrations + + +def load_test_data(apps, schema_editor): + _ = apps, schema_editor # Unused + from ..utils.seed_user_test_data import seed_user_test_data + from django.db import connections + + for alias in connections: + name = connections[alias].settings_dict["NAME"] + if name.startswith("test_"): + seed_user_test_data() + break + + +class Migration(migrations.Migration): + dependencies = [("data", "0014_socminor_seed")] + operations = [ + migrations.RunPython(load_test_data), + ] diff --git a/app/test_data/migrations/__init__.py b/app/test_data/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/test_data/migrations/max_migration.txt b/app/test_data/migrations/max_migration.txt new file mode 100644 index 00000000..7d12dd26 --- /dev/null +++ b/app/test_data/migrations/max_migration.txt @@ -0,0 +1 @@ +0001_populate_test_user_permission diff --git a/app/test_data/utils/__init__.py b/app/test_data/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/test_data/utils/seed_constants.py b/app/test_data/utils/seed_constants.py new file mode 100644 index 00000000..22ac6495 --- /dev/null +++ b/app/test_data/utils/seed_constants.py @@ -0,0 +1,22 @@ +wanda_admin_project = "Wanda" +wally_name = "Wally" +winona_name = "Winona" +zani_name = "Zani" +patti_name = "Patti" +patrick_practice_lead = "Patrick" +valerie_name = "Valerie" +garry_name = "Garry" + +descriptions = { + wally_name: "Website member", + wanda_admin_project: "Website project admin", + winona_name: "Website member", + zani_name: "Website member and People Depot project admin", + patti_name: "People Depot member", + patrick_practice_lead: "People Depot project lead", + valerie_name: "Verified user, no project", + garry_name: "Global admin", +} + +website_project_name = "Website" +people_depot_project = "People Depot" diff --git a/app/test_data/utils/seed_user.py b/app/test_data/utils/seed_user.py new file mode 100644 index 00000000..b2db6500 --- /dev/null +++ b/app/test_data/utils/seed_user.py @@ -0,0 +1,79 @@ +from core.models import PermissionType +from core.models import Project +from core.models import User +from core.models import UserPermission + + +class SeedUser: + """Summary + Attributes: + seed_users_list (dict): Populated by the create_user method. + Used to store the users created by the SeedUser.create_user. + Users are retrieved by first name. The code uses constants + when creating and getting seed users. + """ + + seed_users_list = {} + + def __init__(self, first_name, last_name): + self.first_name = first_name + self.last_name = last_name + self.user_name = f"{first_name}{last_name}@example.com" + self.email = self.user_name + self.user = SeedUser.create_user(first_name=first_name, description=last_name) + self.seed_users_list[first_name] = self.user + + @classmethod + def create_user(cls, *, first_name, description=None): + """Creates a user with the given first_name and description and + stores the user in the seed_users_list dictionary. + """ + last_name = f"{description}" + email = f"{first_name}@example.com" + username = first_name + + user = User.objects.create( + username=username, + first_name=first_name, + last_name=last_name, + email=email, + is_active=True, + ) + cls.seed_users_list[first_name] = user + user.save() + return user + + @classmethod + def create_related_data( + cls, *, user: User, permission_type_name: str, project_name: str = None + ) -> UserPermission: + """ + Create a UserPermission for the given user. + + Args: + user (User): The user to assign permissions to. + permission_type_name (str): Name of the PermissionType to assign. + project_name (str, optional): Name of the Project to link the permission to. + If None, permission is global. + + Returns: + UserPermission: The created UserPermission instance. + """ + # Retrieve PermissionType object from DB + permission_type = PermissionType.objects.get(name=permission_type_name) + if project_name: + project_data = {"project": Project.objects.get(name=project_name)} + else: + project_data = {} + user_permission = UserPermission.objects.create( + user=user, permission_type=permission_type, **project_data + ) + user_permission.save() + return user_permission + + @classmethod + def get_user(cls, first_name): + """Looks up user info from seed_users_list dictionary. + For more info, see notes on seed_users_list in the class docstring. + """ + return cls.seed_users_list.get(first_name) diff --git a/app/test_data/utils/seed_user_test_data.py b/app/test_data/utils/seed_user_test_data.py new file mode 100644 index 00000000..f3267ca8 --- /dev/null +++ b/app/test_data/utils/seed_user_test_data.py @@ -0,0 +1,109 @@ +import copy + +from constants import ADMIN_GLOBAL +from constants import ADMIN_PROJECT +from constants import MEMBER_PROJECT +from constants import PRACTICE_LEAD_PROJECT +from core.models import Project + +from .seed_constants import garry_name +from .seed_constants import patrick_practice_lead +from .seed_constants import patti_name +from .seed_constants import people_depot_project +from .seed_constants import valerie_name +from .seed_constants import wally_name +from .seed_constants import wanda_admin_project +from .seed_constants import website_project_name +from .seed_constants import winona_name +from .seed_constants import zani_name +from .seed_user import SeedUser + + +def seed_user_test_data(): + """Populalates projects, users, and userpermissions with seed data + that is used by the tests in the core app. + + Called from django_db_setup which is automatcallly called by pytest-django + before any test is executed. + + Creates website_project and people_depot projects. Populates users + as follows: + - Wanda is the project lead for the website project + - Wally and Winona are members of the website project + - Patti is a member of the People Depot project + - Patrick is the project lead for the People Depot project + + - Garry is a global admin + - Zani is a member of the website project and the project lead for the People Depot project + - Valerie is a verified user with no UserPermission assignments. + """ + projects = [website_project_name, people_depot_project] + for project_name in projects: + project = Project.objects.create(name=project_name) + project.save() + SeedUser.create_user( + first_name=wanda_admin_project, description="Website project admin" + ) + SeedUser.create_user(first_name=wally_name, description="Website member") + SeedUser.create_user(first_name=winona_name, description="Website member") + SeedUser.create_user( + first_name=zani_name, + description="Website member and People Depot project admin", + ) + SeedUser.create_user(first_name=patti_name, description="People Depot member") + SeedUser.create_user( + first_name=patrick_practice_lead, description="People Depot project admin" + ) + SeedUser.create_user(first_name=garry_name, description="Global admin") + SeedUser.get_user(garry_name).save() + SeedUser.create_user(first_name=valerie_name, description="Verified user") + + related_data = [ + {"first_name": garry_name, "permission_type_name": ADMIN_GLOBAL}, + { + "first_name": garry_name, + "project_name": website_project_name, + "permission_type_name": ADMIN_PROJECT, + }, + { + "first_name": wanda_admin_project, + "project_name": website_project_name, + "permission_type_name": ADMIN_PROJECT, + }, + { + "first_name": wally_name, + "project_name": website_project_name, + "permission_type_name": MEMBER_PROJECT, + }, + { + "first_name": winona_name, + "project_name": website_project_name, + "permission_type_name": MEMBER_PROJECT, + }, + { + "first_name": patti_name, + "project_name": people_depot_project, + "permission_type_name": MEMBER_PROJECT, + }, + { + "first_name": patrick_practice_lead, + "project_name": people_depot_project, + "permission_type_name": PRACTICE_LEAD_PROJECT, + }, + { + "first_name": zani_name, + "project_name": people_depot_project, + "permission_type_name": ADMIN_PROJECT, + }, + { + "first_name": zani_name, + "project_name": website_project_name, + "permission_type_name": MEMBER_PROJECT, + }, + ] + + for data in related_data: + user = SeedUser.get_user(data["first_name"]) + params = copy.deepcopy(data) + del params["first_name"] + SeedUser.create_related_data(user=user, **params)