From b3f92ef182d9c86dcbe0621a0a25f3e9d3e3e377 Mon Sep 17 00:00:00 2001 From: Jake Rosenberg Date: Wed, 25 Sep 2024 16:53:11 -0500 Subject: [PATCH 1/2] Use a Django group to manage project admins --- designsafe/apps/api/datafiles/views.py | 17 ++- designsafe/apps/api/projects_v2/views.py | 153 ++++++------------- designsafe/apps/api/publications_v2/views.py | 30 +--- designsafe/libs/common/utils.py | 14 ++ designsafe/settings/common_settings.py | 5 +- 5 files changed, 83 insertions(+), 136 deletions(-) create mode 100644 designsafe/libs/common/utils.py diff --git a/designsafe/apps/api/datafiles/views.py b/designsafe/apps/api/datafiles/views.py index 347151f064..90f604dbfc 100644 --- a/designsafe/apps/api/datafiles/views.py +++ b/designsafe/apps/api/datafiles/views.py @@ -3,7 +3,9 @@ from boxsdk.exception import BoxOAuthException from django.http import JsonResponse from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.conf import settings +from designsafe.libs.common.utils import check_group_membership from designsafe.apps.api.datafiles.handlers import datafiles_get_handler, datafiles_post_handler, datafiles_put_handler, resource_unconnected_handler, resource_expired_handler from designsafe.apps.api.datafiles.operations.transfer_operations import transfer, transfer_folder from designsafe.apps.api.datafiles.notifications import notify @@ -20,8 +22,12 @@ logger = logging.getLogger(__name__) metrics = logging.getLogger('metrics') +def check_project_admin_group(user): + """Check whether a user belongs to the Project Admin group""" + return check_group_membership(user, settings.PROJECT_ADMIN_GROUP) -def get_client(user, api): + +def get_client(user, api, system=""): client_mappings = { 'agave': 'tapis_oauth', 'tapis': 'tapis_oauth', @@ -30,6 +36,9 @@ def get_client(user, api): 'box': 'box_user_token', 'dropbox': 'dropbox_user_token' } + if api == 'tapis' and system.startswith("project-") and check_project_admin_group(user): + # Project admin users have full access to project systems. + return service_account() return getattr(user, client_mappings[api]).client @@ -55,7 +64,7 @@ def get(self, request, api, operation=None, scheme='private', system=None, path= if request.user.is_authenticated: try: - client = get_client(request.user, api) + client = get_client(request.user, api, system) except AttributeError: raise resource_unconnected_handler(api) elif api in ('agave', 'tapis') and system in (settings.COMMUNITY_SYSTEM, @@ -99,7 +108,7 @@ def put(self, request, api, operation=None, scheme='private', system=None, path= client = None if request.user.is_authenticated: try: - client = get_client(request.user, api) + client = get_client(request.user, api, system) except AttributeError: raise resource_unconnected_handler(api) @@ -131,7 +140,7 @@ def post(self, request, api, operation=None, scheme='private', system=None, path if request.user.is_authenticated: try: - client = get_client(request.user, api) + client = get_client(request.user, api, system) except AttributeError: raise resource_unconnected_handler(api) diff --git a/designsafe/apps/api/projects_v2/views.py b/designsafe/apps/api/projects_v2/views.py index 1f7791ba3e..884d22bc57 100644 --- a/designsafe/apps/api/projects_v2/views.py +++ b/designsafe/apps/api/projects_v2/views.py @@ -4,9 +4,11 @@ import json import networkx as nx from django.http import HttpRequest, JsonResponse +from django.conf import settings from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from django.db import models +from designsafe.libs.common.utils import check_group_membership from designsafe.apps.api.views import BaseApiView, ApiException from designsafe.apps.api.projects_v2.models.project_metadata import ProjectMetadata from designsafe.apps.api.projects_v2.schema_models.base import BaseProject @@ -51,6 +53,31 @@ logger = logging.getLogger(__name__) +def check_project_admin_group(user) -> bool: + """Check whether a user belongs to the Project Admin group""" + return check_group_membership(user, settings.PROJECT_ADMIN_GROUP) + + +def get_project_for_user(project_id, user) -> ProjectMetadata: + """ + Return a project with the specified project_id if the user is authorized to retrieve + it; otherwise throw a 403 error. + """ + if check_project_admin_group(user): + return ProjectMetadata.objects.get( + models.Q(uuid=project_id) | models.Q(value__projectId=project_id) + ) + + try: + return user.projects.get( + models.Q(uuid=project_id) | models.Q(value__projectId=project_id) + ) + except ProjectMetadata.DoesNotExist as exc: + raise ApiException( + "User does not have access to the requested project", status=403 + ) from exc + + def get_search_filter(query_string): """ Construct a search filter for projects. @@ -78,9 +105,15 @@ def get(self, request: HttpRequest): raise ApiException("Unauthenticated user", status=401) projects = user.projects.order_by("-last_updated") + + if check_project_admin_group(user): + projects = ProjectMetadata.objects.filter( + name="designsafe.project" + ).order_by("-last_updated") + if query_string: projects = projects.filter(get_search_filter(query_string)) - total = user.projects.count() + total = projects.count() project_json = { "result": [ @@ -126,14 +159,7 @@ def get(self, request: HttpRequest, project_id: str): if not request.user.is_authenticated: raise ApiException("Unauthenticated user", status=401) - try: - project = user.projects.get( - models.Q(uuid=project_id) | models.Q(value__projectId=project_id) - ) - except ProjectMetadata.DoesNotExist as exc: - raise ApiException( - "User does not have access to the requested project", status=403 - ) from exc + project = get_project_for_user(project_id, user) entities = ProjectMetadata.objects.filter(base_project=project) return JsonResponse( @@ -152,14 +178,7 @@ def put(self, request: HttpRequest, project_id: str): if not request.user.is_authenticated: raise ApiException("Unauthenticated user", status=401) - try: - project: ProjectMetadata = user.projects.get( - models.Q(uuid=project_id) | models.Q(value__projectId=project_id) - ) - except ProjectMetadata.DoesNotExist as exc: - raise ApiException( - "User does not have access to the requested project", status=403 - ) from exc + project = get_project_for_user(project_id, user) # Get the new value from the request data req_body = json.loads(request.body) @@ -190,14 +209,7 @@ def patch(self, request: HttpRequest, project_id: str): if not request.user.is_authenticated: raise ApiException("Unauthenticated user", status=401) - try: - project: ProjectMetadata = user.projects.get( - models.Q(uuid=project_id) | models.Q(value__projectId=project_id) - ) - except ProjectMetadata.DoesNotExist as exc: - raise ApiException( - "User does not have access to the requested project", status=403 - ) from exc + project = get_project_for_user(project_id, user) request_body = json.loads(request.body).get("patchMetadata", {}) prev_metadata = BaseProject.model_validate(project.value) @@ -225,10 +237,7 @@ def patch(self, request: HttpRequest, entity_uuid: str): raise ApiException("Unauthenticated user", status=401) entity_meta = ProjectMetadata.objects.get(uuid=entity_uuid) - if user not in entity_meta.base_project.users.all(): - raise ApiException( - "User does not have access to the requested project", status=403 - ) + get_project_for_user(entity_meta.base_project.project_id, user) request_body = json.loads(request.body).get("patchMetadata", {}) logger.debug(request_body) @@ -242,10 +251,7 @@ def delete(self, request: HttpRequest, entity_uuid: str): raise ApiException("Unauthenticated user", status=401) entity_meta = ProjectMetadata.objects.get(uuid=entity_uuid) - if user not in entity_meta.base_project.users.all(): - raise ApiException( - "User does not have access to the requested project", status=403 - ) + get_project_for_user(entity_meta.base_project.project_id, user) remove_nodes_for_entity(entity_meta.project_id, entity_uuid) delete_entity(entity_uuid) @@ -286,14 +292,7 @@ def get(self, request: HttpRequest, project_id: str): if not request.user.is_authenticated: raise ApiException("Unauthenticated user", status=401) - try: - project = user.projects.get( - models.Q(uuid=project_id) | models.Q(value__projectId=project_id) - ) - except ProjectMetadata.DoesNotExist as exc: - raise ApiException( - "User does not have access to the requested project", status=403 - ) from exc + project = get_project_for_user(project_id, user) entities = ProjectMetadata.objects.filter(base_project=project) preview_tree = add_values_to_tree(project.project_id) return JsonResponse( @@ -321,14 +320,7 @@ def put(self, request: HttpRequest, project_id: str): if not request.user.is_authenticated: raise ApiException("Unauthenticated user", status=401) - try: - project = user.projects.get( - models.Q(uuid=project_id) | models.Q(value__projectId=project_id) - ) - except ProjectMetadata.DoesNotExist as exc: - raise ApiException( - "User does not have access to the requested project", status=403 - ) from exc + project = get_project_for_user(project_id, user) project_id = project.project_id reorder_project_nodes(project_id, node_id, order) @@ -351,14 +343,7 @@ def post(self, request: HttpRequest, project_id, node_id): if not request.user.is_authenticated: raise ApiException("Unauthenticated user", status=401) - try: - project = user.projects.get( - models.Q(uuid=project_id) | models.Q(value__projectId=project_id) - ) - except ProjectMetadata.DoesNotExist as exc: - raise ApiException( - "User does not have access to the requested project", status=403 - ) from exc + project = get_project_for_user(project_id, user) entity_meta = ProjectMetadata.objects.get( uuid=entity_uuid, base_project=project @@ -375,14 +360,7 @@ def delete(self, request: HttpRequest, project_id, node_id): if not request.user.is_authenticated: raise ApiException("Unauthenticated user", status=401) - try: - project = user.projects.get( - models.Q(uuid=project_id) | models.Q(value__projectId=project_id) - ) - except ProjectMetadata.DoesNotExist as exc: - raise ApiException( - "User does not have access to the requested project", status=403 - ) from exc + project = get_project_for_user(project_id, user) remove_nodes_from_project(project.project_id, node_ids=[node_id]) @@ -415,14 +393,7 @@ def patch(self, request: HttpRequest, project_id, entity_uuid): if not request.user.is_authenticated: raise ApiException("Unauthenticated user", status=401) - try: - project = user.projects.get( - models.Q(uuid=project_id) | models.Q(value__projectId=project_id) - ) - except ProjectMetadata.DoesNotExist as exc: - raise ApiException( - "User does not have access to the requested project", status=403 - ) from exc + project = get_project_for_user(project_id, user) try: ProjectMetadata.objects.get(uuid=entity_uuid, base_project=project) @@ -457,14 +428,7 @@ def put(self, request: HttpRequest, project_id, entity_uuid): if not request.user.is_authenticated: raise ApiException("Unauthenticated user", status=401) - try: - project = user.projects.get( - models.Q(uuid=project_id) | models.Q(value__projectId=project_id) - ) - except ProjectMetadata.DoesNotExist as exc: - raise ApiException( - "User does not have access to the requested project", status=403 - ) from exc + project = get_project_for_user(project_id, user) try: ProjectMetadata.objects.get(uuid=entity_uuid, base_project=project) @@ -486,14 +450,7 @@ def delete(self, request: HttpRequest, project_id, entity_uuid, file_path): if not request.user.is_authenticated: raise ApiException("Unauthenticated user", status=401) - try: - project = user.projects.get( - models.Q(uuid=project_id) | models.Q(value__projectId=project_id) - ) - except ProjectMetadata.DoesNotExist as exc: - raise ApiException( - "User does not have access to the requested project", status=403 - ) from exc + project = get_project_for_user(project_id, user) try: ProjectMetadata.objects.get(uuid=entity_uuid, base_project=project) @@ -521,14 +478,7 @@ def put(self, request: HttpRequest, project_id, entity_uuid, file_path): if not request.user.is_authenticated: raise ApiException("Unauthenticated user", status=401) - try: - project = user.projects.get( - models.Q(uuid=project_id) | models.Q(value__projectId=project_id) - ) - except ProjectMetadata.DoesNotExist as exc: - raise ApiException( - "User does not have access to the requested project", status=403 - ) from exc + project = get_project_for_user(project_id, user) try: ProjectMetadata.objects.get(uuid=entity_uuid, base_project=project) @@ -550,14 +500,7 @@ def post(self, request: HttpRequest, project_id): if not request.user.is_authenticated: raise ApiException("Unauthenticated user", status=401) - try: - project: ProjectMetadata = user.projects.get( - models.Q(uuid=project_id) | models.Q(value__projectId=project_id) - ) - except ProjectMetadata.DoesNotExist as exc: - raise ApiException( - "User does not have access to the requested project", status=403 - ) from exc + project = get_project_for_user(project_id, user) entities: list[str] = json.loads(request.body).get("entityUuids", None) diff --git a/designsafe/apps/api/publications_v2/views.py b/designsafe/apps/api/publications_v2/views.py index 61f76eb1d9..3209a77929 100644 --- a/designsafe/apps/api/publications_v2/views.py +++ b/designsafe/apps/api/publications_v2/views.py @@ -3,16 +3,15 @@ import logging import json import networkx as nx -from django.db import models from django.http import HttpRequest, JsonResponse from designsafe.apps.api.views import BaseApiView, ApiException from designsafe.apps.api.publications_v2.models import Publication from designsafe.apps.api.publications_v2.elasticsearch import IndexedPublication -from designsafe.apps.api.projects_v2.models.project_metadata import ProjectMetadata from designsafe.apps.api.projects_v2.operations.project_publish_operations import ( publish_project_async, amend_publication_async, ) +from designsafe.apps.api.projects_v2.views import get_project_for_user logger = logging.getLogger(__name__) @@ -250,14 +249,7 @@ def post(self, request: HttpRequest): if (not project_id) or (not entities_to_publish): raise ApiException("Missing project ID or entity list.", status=400) - try: - user.projects.get( - models.Q(uuid=project_id) | models.Q(value__projectId=project_id) - ) - except ProjectMetadata.DoesNotExist as exc: - raise ApiException( - "User does not have access to the requested project", status=403 - ) from exc + get_project_for_user(project_id, user) publish_project_async.apply_async([project_id, entities_to_publish]) logger.debug(project_id) @@ -281,14 +273,7 @@ def post(self, request: HttpRequest): if (not project_id) or (not entities_to_publish): raise ApiException("Missing project ID or entity list.", status=400) - try: - user.projects.get( - models.Q(uuid=project_id) | models.Q(value__projectId=project_id) - ) - except ProjectMetadata.DoesNotExist as exc: - raise ApiException( - "User does not have access to the requested project", status=403 - ) from exc + get_project_for_user(project_id, user) pub_root = Publication.objects.get(project_id=project_id) pub_tree: nx.DiGraph = nx.node_link_graph(pub_root.tree) @@ -318,14 +303,7 @@ def post(self, request: HttpRequest): if not project_id: raise ApiException("Missing project ID.", status=400) - try: - user.projects.get( - models.Q(uuid=project_id) | models.Q(value__projectId=project_id) - ) - except ProjectMetadata.DoesNotExist as exc: - raise ApiException( - "User does not have access to the requested project", status=403 - ) from exc + get_project_for_user(project_id, user) amend_publication_async.apply_async([project_id]) logger.debug(project_id) diff --git a/designsafe/libs/common/utils.py b/designsafe/libs/common/utils.py new file mode 100644 index 0000000000..3f8c3ce31c --- /dev/null +++ b/designsafe/libs/common/utils.py @@ -0,0 +1,14 @@ +""" +Utility functions shared across multiple apps. +""" + +from django.contrib.auth.models import Group + + +def check_group_membership(user, group_name: str) -> bool: + """Check whether a user belongs to the Project Admin group""" + try: + user.groups.get(name=group_name) + return True + except Group.DoesNotExist: + return False diff --git a/designsafe/settings/common_settings.py b/designsafe/settings/common_settings.py index ca1f6ada5d..5eb78f58f7 100644 --- a/designsafe/settings/common_settings.py +++ b/designsafe/settings/common_settings.py @@ -402,7 +402,8 @@ # ##### IMPERSONATE = { - 'REQUIRE_SUPERUSER': True + 'REQUIRE_SUPERUSER': True, + 'ADMIN_DELETE_PERMISSION': True } @@ -567,6 +568,8 @@ DS_ADMIN_USERNAME = os.environ.get('DS_ADMIN_USERNAME') DS_ADMIN_PASSWORD = os.environ.get('DS_ADMIN_PASSWORD') +PROJECT_ADMIN_GROUP = os.environ.get("PROJECT_ADMIN_GROUP", "Project Admin") + PROJECT_STORAGE_SYSTEM_TEMPLATE = { 'id': 'project-{}', 'site': 'tacc.utexas.edu', From f0bb67258051c4fa122bcb8d7b3abf631e6e2f5d Mon Sep 17 00:00:00 2001 From: Jake Rosenberg Date: Wed, 25 Sep 2024 17:13:08 -0500 Subject: [PATCH 2/2] update test settings --- designsafe/settings/test_settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/designsafe/settings/test_settings.py b/designsafe/settings/test_settings.py index 10ab3fa3d9..4ba86fb0ba 100644 --- a/designsafe/settings/test_settings.py +++ b/designsafe/settings/test_settings.py @@ -482,6 +482,7 @@ } } PROJECT_ADMIN_USERS = ["test_prjadmin"] +PROJECT_ADMIN_GROUP = "Project Admin" PUBLISHED_SYSTEM = 'designsafe.storage.published'