diff --git a/designsafe/apps/api/datafiles/views.py b/designsafe/apps/api/datafiles/views.py index 347151f06..90f604dbf 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 adac8a0e6..e48352c6e 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 @@ -53,6 +55,31 @@ metrics = logging.getLogger("metrics") +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. @@ -92,9 +119,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": [ @@ -165,14 +198,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( @@ -191,14 +217,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) @@ -242,14 +261,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", {}) @@ -290,10 +302,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", {}) @@ -332,10 +341,7 @@ def delete(self, request: HttpRequest, entity_uuid: str): ) 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) @@ -389,14 +395,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( @@ -436,14 +435,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) @@ -482,14 +474,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 @@ -519,14 +504,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]) @@ -575,14 +553,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) @@ -633,14 +604,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) @@ -678,14 +642,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) @@ -729,14 +686,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) @@ -773,14 +723,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 7a9ffb535..39293262d 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 from designsafe.apps.api.utils import get_client_ip logger = logging.getLogger(__name__) @@ -288,14 +287,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) @@ -331,14 +323,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) @@ -380,14 +365,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 000000000..3f8c3ce31 --- /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 94c5eae3b..047ae1324 100644 --- a/designsafe/settings/common_settings.py +++ b/designsafe/settings/common_settings.py @@ -403,7 +403,8 @@ # ##### IMPERSONATE = { - 'REQUIRE_SUPERUSER': True + 'REQUIRE_SUPERUSER': True, + 'ADMIN_DELETE_PERMISSION': True } @@ -561,6 +562,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', diff --git a/designsafe/settings/test_settings.py b/designsafe/settings/test_settings.py index b45a0afaf..d62cde6bd 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'