diff --git a/aai/access.py b/aai/access.py new file mode 100644 index 0000000000000000000000000000000000000000..4aade1fe4c3e3ae0e34b1593aa29293e9f46b51e --- /dev/null +++ b/aai/access.py @@ -0,0 +1,84 @@ +from typing import Optional + +from django.conf import settings +from django.contrib.auth.models import AnonymousUser +from rest_framework.exceptions import PermissionDenied, AuthenticationFailed +from rest_framework.request import Request + +from user.models import User, UserInTeam, DefinitionAccess, InstructorOfExercise + + +def user_from_context(context: Request) -> Optional[User]: + user: Optional[User] = None if settings.NOAUTH else context.user + if isinstance(user, AnonymousUser): + raise AuthenticationFailed("Authentication failed") + return user + + +def team_access(context: Request, team_id: int): + user = user_from_context(context) + if user is None: + return + + if user.group == User.AuthGroup.ADMIN: + return + + condition = False + if user.group == User.AuthGroup.INSTRUCTOR: + condition = InstructorOfExercise.objects.filter( + user_id=user.id, exercise__teams__in=[team_id] + ).exists() + if user.group == User.AuthGroup.TRAINEE: + condition = UserInTeam.objects.filter( + user_id=user.id, team_id=team_id + ).exists() + + if not condition: + raise PermissionDenied( + f"User does not have access to this team ({team_id})" + ) + + +def exercise_access(context: Request, exercise_id: int): + user = user_from_context(context) + if user is None: + return + + if user.group == User.AuthGroup.ADMIN: + return + + condition = False + if user.group == User.AuthGroup.INSTRUCTOR: + condition = InstructorOfExercise.objects.filter( + user_id=user.id, exercise_id=exercise_id + ).exists() + if user.group == User.AuthGroup.TRAINEE: + condition = UserInTeam.objects.filter( + user_id=user.id, user__teams__exercise_id=exercise_id + ).exists() + + if not condition: + raise PermissionDenied( + f"User does not have access to this exercise ({exercise_id})" + ) + + +def definition_access(context: Request, definition_id: int): + user = user_from_context(context) + if user is None: + return + + if user.group == User.AuthGroup.ADMIN: + return + + condition = False + if user.group == User.AuthGroup.INSTRUCTOR: + condition = DefinitionAccess.objects.filter( + user_id=user.id, definition_id=definition_id + ).exists() + + # trainee should never reach this, but just to be sure + if not condition: + raise PermissionDenied( + f"User does not have access to this definition ({definition_id})" + ) diff --git a/aai/decorators.py b/aai/decorators.py new file mode 100644 index 0000000000000000000000000000000000000000..33236de040abc2e56429196f648bbccb7c3783ff --- /dev/null +++ b/aai/decorators.py @@ -0,0 +1,117 @@ +from typing import Optional + +from django.conf import settings +from graphene import ResolveInfo +from rest_framework.exceptions import AuthenticationFailed, PermissionDenied +from rest_framework.request import HttpRequest, Request + +from aai.access import user_from_context +from common_lib.logger import logger +from user.models import User + + +# This function is probably significantly overcomplicated +def _get_action_name(func, *args) -> str: + try: + # Should never happen + if len(args) == 0: + return "Unknown" + + # I don't know a name for this variable + tmp = args[0] + + # This case should happen when the decorator is on a Query + if tmp is None: + # A query function should always have qualname, but just in case + if hasattr(func, "__qualname__"): + # qualname should give us a name in the format Query.resolve<name> + return getattr(func, "__qualname__") + # Should never happen + return "Unknown" + + # This case works for mutations and subscriptions where it just gives us the class name + if hasattr(tmp, "__name__"): + return getattr(tmp, "__name__") + + # Last case, should only happen for views + tmp_t = type(tmp) + if hasattr(tmp_t, "__name__"): + return getattr(tmp_t, "__name__") + + # Again, should never happen as all of our API options are covered + return "Unknown" + # we CANNOT throw an exception in here + except Exception: + return "Unknown" + + +def _get_user(*args, **kwargs) -> Optional[User]: + """ + Gets user object from known request types. + + Args: + *args - non-keyworded arguments of request + **kwargs - keyword arguments of request + Returns: + Instance of User if request is authenticated else instance of AnonymousUser + """ + if len(args) == 0: + return None + + request = args[-1] # get request + context: Optional[Request] = None + + if isinstance(request, ResolveInfo): + context = request.context + + if isinstance(request, HttpRequest) or isinstance(request, Request): + context = request + + return user_from_context(context) if context is not None else None + + +def logged_in(func): + def wrapper(*args, **kwargs): + user = _get_user(*args, **kwargs) + if user is not None: + return func(*args, **kwargs) + raise AuthenticationFailed("Authentication failed") + + return wrapper + + +def protected(required_group: User.AuthGroup): + """ + Decorator that checks whether user of request has required authorization group. + If not, PermissionDenied exception is raised. + + Args: + required_group: minimal required authorization group to access endpoint + """ + + def decorator(func): + def wrapper(*args, **kwargs): + action_name = _get_action_name(func, *args) + log_text = f"action: {action_name} | arguments: {kwargs}" + + if settings.NOAUTH: + if action_name is not None: + logger.info(log_text) + return func(*args, **kwargs) + + user = _get_user(*args, **kwargs) + if user is None: + log_text += f" | username: Unauthenticated" + logger.info(log_text) + raise AuthenticationFailed("Authentication failed") + + log_text += f" | username: {user.username}" + logger.info(log_text) + + if user.group >= required_group: + return func(*args, **kwargs) + raise PermissionDenied("Permission denied") + + return wrapper + + return decorator diff --git a/aai/schema/mutation.py b/aai/schema/mutation.py index 50485fbb3652e0e5043ebcc0784f8c8660169be3..2feea84ba2dfd855ebdbae3b25486b2b63c81a1c 100644 --- a/aai/schema/mutation.py +++ b/aai/schema/mutation.py @@ -3,7 +3,7 @@ from django.conf import settings from django.contrib.auth import authenticate, logout, login from django.core.exceptions import ValidationError -from aai.utils import logged_in +from aai.decorators import logged_in from common_lib.logger import logger, log_user_msg from common_lib.schema_types import UserType from user.email.email_sender import send_password_change_notification diff --git a/aai/tests/aai_utils_tests.py b/aai/tests/authorization_tests.py similarity index 56% rename from aai/tests/aai_utils_tests.py rename to aai/tests/authorization_tests.py index ee012b71a15a793196d24157f3dcf46909ccc5d7..e19239bca8dbcd9d3e3a972f8530d3405d694c41 100644 --- a/aai/tests/aai_utils_tests.py +++ b/aai/tests/authorization_tests.py @@ -2,12 +2,13 @@ import os import shutil from django.conf import settings -from django.contrib.auth.models import AnonymousUser, Permission -from rest_framework.exceptions import PermissionDenied, AuthenticationFailed +from django.contrib.auth.models import AnonymousUser from django.test import TestCase, override_settings +from rest_framework.exceptions import PermissionDenied, AuthenticationFailed from rest_framework.test import APIRequestFactory -from aai.utils import protected, extra_protected, Check +from aai.access import team_access, exercise_access, definition_access +from aai.decorators import protected from common_lib.test_utils import ( internal_create_exercise, internal_upload_definition, @@ -25,14 +26,14 @@ from running_exercise.models import ( ) from user.models import User -TEST_DATA_STORAGE = "aai_tests_test_data" +TEST_DATA_STORAGE = "authorization_test_data" @override_settings( DATA_STORAGE=TEST_DATA_STORAGE, FILE_STORAGE=os.path.join(TEST_DATA_STORAGE, "files"), ) -class AaiUtilsTests(TestCase): +class AuthorizationTests(TestCase): definition: Definition exercise: Exercise exercise2: Exercise @@ -200,7 +201,7 @@ class AaiUtilsTests(TestCase): request.user = self.admin self.assertEqual(decorated(request), 0) - def test_protected_anonoymous(self): + def test_protected_anonymous(self): decorated = protected(User.AuthGroup.TRAINEE)(self.dummy) request = self.factory.request() @@ -208,148 +209,64 @@ class AaiUtilsTests(TestCase): with self.assertRaises(AuthenticationFailed): decorated(request) - def test_team_protected(self): + def test_team_access(self): request = self.factory.request() - decorated = extra_protected(Check.TEAM_ID)(self.team_dummy) + # access + # team1: trainee1, trainee2 + # team2: empty + # team3: trainee3 request.user = self.trainee1 - self.assertEqual(decorated(request, team_id=self.team1.id), 0) + self.assertIsNone(team_access(request, self.team1.id)) with self.assertRaises(PermissionDenied): - decorated(request, team_id=self.team2.id) + team_access(request, self.team2.id) request.user = self.trainee2 - self.assertEqual(decorated(request, team_id=self.team1.id), 0) + self.assertIsNone(team_access(request, self.team1.id)) with self.assertRaises(PermissionDenied): - decorated(request, team_id=self.team2.id) + team_access(request, self.team2.id) request.user = self.trainee3 - self.assertEqual(decorated(request, team_id=self.team3.id), 0) + self.assertIsNone(team_access(request, self.team3.id)) with self.assertRaises(PermissionDenied): - decorated(request, team_id=self.team2.id) - - request.user = AnonymousUser() - with self.assertRaises(AuthenticationFailed): - decorated(request, team_id=self.team2.id) + team_access(request, self.team2.id) def test_exercise_protected(self): request = self.factory.request() - decorated = extra_protected(Check.EXERCISE_ID)(self.exercise_dummy) + + # access + # exercise: instructor, trainee1-3 + # exercise2: empty request.user = self.instructor - self.assertEqual(decorated(request, exercise_id=self.exercise.id), 0) + self.assertIsNone(exercise_access(request, self.exercise.id)) with self.assertRaises(PermissionDenied): - decorated(request, exercise_id=self.exercise2.id) + exercise_access(request, self.exercise2.id) request.user = self.admin - self.assertEqual(decorated(request, exercise_id=self.exercise.id), 0) - self.assertEqual(decorated(request, exercise_id=self.exercise2.id), 0) + self.assertIsNone(exercise_access(request, self.exercise.id)) + self.assertIsNone(exercise_access(request, self.exercise2.id)) request.user = self.trainee1 - self.assertEqual(decorated(request, exercise_id=self.exercise.id), 0) + self.assertIsNone(exercise_access(request, self.exercise.id)) with self.assertRaises(PermissionDenied): - decorated(request, exercise_id=self.exercise2.id) - - request.user = AnonymousUser() - with self.assertRaises(AuthenticationFailed): - decorated(request, team_id=self.team2.id) + exercise_access(request, self.exercise2.id) def test_definition_protected(self): request = self.factory.request() - decorated = extra_protected(Check.DEFINITION_ID)(self.definition_dummy) + # access + # definition: instructor request.user = self.instructor - self.assertEqual( - decorated(request, definition_id=self.definition.id), 0 - ) # instructor with access to definition + self.assertIsNone(definition_access(request, self.definition.id)) request.user = self.instructor2 - # instructor without access to definition with self.assertRaises(PermissionDenied): - decorated(request, definition_id=self.definition.id) + definition_access(request, self.definition.id) request.user = self.admin - # admin should see all definition - self.assertEqual( - decorated(request, definition_id=self.definition.id), 0 - ) - - request.user = self.trainee1 - # trainee should't see the definition - with self.assertRaises(PermissionDenied): - decorated(request, definition_id=self.definition.id) - - def test_log_protected(self): - request = self.factory.request() - decorated = extra_protected(Check.LOG_ID)(self.log_dummy) - - request.user = self.trainee1 - # trainee1 created log -> can access it - self.assertEqual(decorated(request, log_id=self.log.id), 0) - - request.user = self.trainee2 - # trainee1_1 is in the same team as trainee1 who created the log thus - # can read the log as well - self.assertEqual(decorated(request, log_id=self.log.id), 0) - - request.user = self.instructor - # instructor is assigned to the exercise where log was created thus can access it - self.assertEqual(decorated(request, log_id=self.log.id), 0) - - request.user = self.admin - # admin can see all logs - self.assertEqual(decorated(request, log_id=self.log.id), 0) - - request.user = self.trainee3 - # trainee3 belongs to different team of the exercise thus can't read other teams logs - with self.assertRaises(PermissionDenied): - decorated(request, log_id=self.log.id) - - request.user = self.instructor2 - # instructor2 is not assigned to the exercise thus can't access the log - with self.assertRaises(PermissionDenied): - decorated(request, log_id=self.log.id) - - def test_thread_protected(self): - request = self.factory.request() - decorated = extra_protected(Check.THREAD_ID)(self.thread_dummy) - - request.user = self.trainee1 - # trainee1 created thread thus can access it - self.assertEqual(decorated(request, thread_id=self.thread.id), 0) - - request.user = self.trainee2 - # trainee2 is in the same team as trainee1 thus can access it - self.assertEqual(decorated(request, thread_id=self.thread.id), 0) - - request.user = self.instructor - # instructor is assigned to the exercise thus can access all threads of exercise - self.assertEqual(decorated(request, thread_id=self.thread.id), 0) - - request.user = self.admin - # admin can access all thread - self.assertEqual(decorated(request, thread_id=self.thread.id), 0) - - request.user = self.instructor2 - # instructor2 is not assigned to the exercise where thread belongs thus cant access it - with self.assertRaises(PermissionDenied): - decorated(request, thread_id=self.thread.id) - - request.user = self.trainee3 - # trainee3 does not belong to any participating teams of the thread - with self.assertRaises(PermissionDenied): - decorated(request, thread_id=self.thread.id) - - def test_visible_protected(self): - request = self.factory.request() - decorated = extra_protected(Check.VISIBLE_ONLY)(self.visibility_dummy) - - request.user = self.instructor - # instructor can see both visible only and all data - self.assertEqual(decorated(request, visible_only=False), 0) - self.assertEqual(decorated(request, visible_only=True), 0) + self.assertIsNone(definition_access(request, self.definition.id)) request.user = self.trainee1 - # trainee can see only visible_only=True data - self.assertEqual(decorated(request, visible_only=True), 0) with self.assertRaises(PermissionDenied): - decorated(request, visible_only=False) + definition_access(request, self.definition.id) diff --git a/aai/tests/special_case_tests.py b/aai/tests/special_case_tests.py new file mode 100644 index 0000000000000000000000000000000000000000..b9a27f32bfc96a10cd05b1d420dd401565444afb --- /dev/null +++ b/aai/tests/special_case_tests.py @@ -0,0 +1,136 @@ +import os +import shutil + +from django.conf import settings +from django.test import override_settings + +from common_lib.graphql import ( + GraphQLApiTestCase, + EmailThreadAction, + SendEmailAction, +) +from common_lib.test_utils import ( + internal_upload_definition, + internal_create_exercise, +) +from exercise.models import Exercise +from exercise_definition.models import Definition +from running_exercise.lib.email_client import EmailClient +from running_exercise.models import EmailThread +from user.models import User + +TEST_DATA_STORAGE = "special_case_tests_data" + + +@override_settings( + DATA_STORAGE=TEST_DATA_STORAGE, + FILE_STORAGE=os.path.join(TEST_DATA_STORAGE, "files"), +) +class SpecialCaseTests(GraphQLApiTestCase): + # These tests are temporary (Clueless) and should be removed + # after we add exercise/team context to all graphql requests + definition: Definition + + exercise: Exercise + + instructor1: User + instructor2: User + trainee1: User + trainee2: User + + thread: EmailThread + all_thread: EmailThread + + @classmethod + def setUpTestData(cls): + cls.definition = internal_upload_definition("emails_definition") + + cls.exercise = internal_create_exercise(cls.definition.id, 2) + + cls.instructor1 = User.objects.create_staffuser( + "instructor1@instructor.com", "instructor" + ) + cls.instructor2 = User.objects.create_staffuser( + "instructor2@instructor.com", "instructor" + ) + cls.trainee1 = User.objects.create_user("user1@user.com", "user") + cls.trainee2 = User.objects.create_user("user2@user.com", "user") + + cls.instructor1.definitions.add(cls.definition) + + cls.instructor1.exercises.add(cls.exercise) + + cls.trainee1.teams.add(cls.exercise.teams.first()) + + cls.thread = EmailClient.create_thread( + ["team-1@mail.com", "doe@mail.ex"], "test", cls.exercise.id + ) + cls.all_thread = EmailClient.create_thread( + ["team-1@mail.com", "team-2@mail.com", "doe@mail.ex"], + "test", + cls.exercise.id, + ) + + @classmethod + def tearDownClass(cls): + shutil.rmtree(settings.DATA_STORAGE) + super().tearDownClass() + + def test_send_email(self): + action = SendEmailAction( + { + "thread_id": self.thread.id, + "sender_address": "team-1@mail.com", + "content": "hello", + }, + {}, + ) + + # valid + self.run_action(action, user=self.trainee1) + + # trainee2 does not have access to the exercise + self.run_action(action, user=self.trainee2, should_throw=True) + + self.trainee2.teams.add(self.exercise.teams.last()) + # trainee2 impersonating team-1 without access to thread + self.run_action(action, user=self.trainee2, should_throw=True) + + action.variables["thread_id"] = self.all_thread.id + self.run_action(action, user=self.trainee1) + + # trainee2 impersonating team-1 with access to thread + self.run_action(action, user=self.trainee2, should_throw=True) + + # trainee1 impersonating a definition address + action.variables["sender_address"] = "doe@mail.ex" + self.run_action(action, user=self.trainee1, should_throw=True) + + # trainee2 impersonating a definition address without access to thread + action.variables["thread_id"] = self.thread.id + self.run_action(action, user=self.trainee2, should_throw=True) + + self.run_action(action, user=self.instructor1) + self.run_action(action, user=self.instructor2, should_throw=True) + + def test_email_thread(self): + action = EmailThreadAction({"thread_id": self.thread.id}) + + self.run_action(action, user=self.trainee1) + self.assertEqual(int(action.result["id"]), self.thread.id) + + # trainee2 does not have access to the exercise + self.run_action(action, user=self.trainee2, should_throw=True) + + # trainee2 now has access to the exercise, but not to this thread + self.trainee2.teams.add(self.exercise.teams.last()) + self.run_action(action, user=self.trainee2, should_throw=True) + + # both trainees can access this thread + action.variables["thread_id"] = self.all_thread.id + self.run_action(action, user=self.trainee1) + self.run_action(action, user=self.trainee2) + + # instructor2 does not have access to this exercise + self.run_action(action, user=self.instructor1) + self.run_action(action, user=self.instructor2, should_throw=True) diff --git a/aai/utils.py b/aai/utils.py deleted file mode 100644 index c9b7286f8ba85c57e885934a6a0f9592baa252a2..0000000000000000000000000000000000000000 --- a/aai/utils.py +++ /dev/null @@ -1,337 +0,0 @@ -from enum import Enum -from typing import Union, Dict, Callable, Optional - -from django.conf import settings -from django.contrib.auth.models import AnonymousUser -from graphene import ResolveInfo -from rest_framework.exceptions import AuthenticationFailed, PermissionDenied -from rest_framework.request import HttpRequest, Request - -from common_lib.logger import logger -from common_lib.utils import get_model, InputObject -from exercise.graphql_inputs import CreateExerciseInput -from exercise.models import Team, Exercise -from running_exercise.graphql_inputs import ( - UseToolInput, - SendEmailInput, - SelectTeamInjectInput, -) -from running_exercise.models import EmailThread, ActionLog, EmailParticipant -from user.models import User - -INSTRUCTOR = -1 - - -class Check(str, Enum): - TEAM_ID = "team_id" - EXERCISE_ID = "exercise_id" - DEFINITION_ID = "definition_id" - LOG_ID = "log_id" - THREAD_ID = "thread_id" - VISIBLE_ONLY = "visible_only" - - -# This function is probably significantly overcomplicated -def _get_action_name(func, *args) -> Optional[str]: - try: - # Should never happen - if len(args) == 0: - return "Unknown" - - # This should correctly check whether the provided `func` - # is a decorator or the actual function - if hasattr(func, "__qualname__") and "decorator" in getattr( - func, "__qualname__" - ): - return None - - # I don't know a name for this variable - tmp = args[0] - - # This case should happen when the decorator is on a Query - if tmp is None: - # A query function should always have qualname, but just in case - if hasattr(func, "__qualname__"): - # qualname should give us a name in the format Query.resolve<name> - return getattr(func, "__qualname__") - # Should never happen - return "Unknown" - - # This case works for mutations and subscriptions where it just gives us the class name - if hasattr(tmp, "__name__"): - return getattr(tmp, "__name__") - - # Last case, should only happen for views - tmp_t = type(tmp) - if hasattr(tmp_t, "__name__"): - return getattr(tmp_t, "__name__") - - # Again, should never happen as all of our API options are covered - return "Unknown" - # we CANNOT throw an exception in here - except Exception: - return "Unknown" - - -def _get_user(*args, **kwargs) -> Union[AnonymousUser, User]: - """ - Gets user object from known request types. - - Args: - *args - non-keyworded arguments of request - **kwargs - keyword arguments of request - Returns: - Instance of User if request is authenticated else instance of AnonymousUser - """ - if len(args) == 0: - return AnonymousUser() - - request = args[-1] # get request - - if isinstance(request, ResolveInfo): - return request.context.user - - if isinstance(request, HttpRequest) or isinstance(request, Request): - return request.user - return AnonymousUser() - - -def _get_param_id_from_input_obj( - input_obj: InputObject, *args, **kwargs -) -> int: - """ - Helper function for retrieving id parameter from the current input objects. - - Returns: - Id parameter needed for authorization from input object. - """ - id_parameter = None - if isinstance(input_obj, (SelectTeamInjectInput, UseToolInput)): - id_parameter = input_obj.team_id - - elif isinstance(input_obj, SendEmailInput): - # TODO: this needs rework - thread = get_model(EmailThread, id=input_obj.thread_id) - participant_team = EmailParticipant.objects.get( - address=input_obj.sender_address, exercise_id=thread.exercise_id - ).team - id_parameter = ( - participant_team.id if participant_team is not None else INSTRUCTOR - ) - - elif isinstance(input_obj, CreateExerciseInput): - id_parameter = input_obj.definition_id - - if id_parameter is None: - raise ValueError("Unexpected input object argument") - return int(id_parameter) - - -def _check_team(user: User, team_id: int) -> bool: - if user.teams.filter(id=team_id).exists(): - # user is in team which he wants to get data of - return True - exercises = user.exercises.all() - # user is instructor of the exercise where given team belongs - return Team.objects.filter(exercise__in=exercises, id=team_id).exists() - - -def _check_exercise(user: User, exercise_id) -> bool: - if user.exercises.filter(id=exercise_id).exists(): - # user is instructor of the exercise - return True - # user is trainee of the exercise - return user.teams.filter(exercise_id=exercise_id).exists() - - -def _check_definition(user: User, definition_id) -> bool: - # user is maintainer of the definition - return user.definitions.filter(id=definition_id).exists() - - -def _check_log(user: User, log_id) -> bool: - return _check_team(user, int(get_model(ActionLog, id=log_id).team_id)) - - -def _check_thread(user: User, thread_id) -> bool: - thread = get_model(EmailThread, id=thread_id) - if user.exercises.filter(id=thread.exercise_id).exists(): - # user is instructor of the exercise with the thread - return True - - participant_teams = thread.participants.exclude(team_id=None).values_list( - "team_id", flat=True - ) - # user is trainee of the team participating in the thread - return user.teams.filter(id__in=participant_teams).exists() - - -def _check_visible_only(user: User, visible_only: bool) -> bool: - return visible_only or user.group != User.AuthGroup.TRAINEE - - -CHECK_MAPPING: Dict[Check, Callable] = { - Check.TEAM_ID: _check_team, - Check.EXERCISE_ID: _check_exercise, - Check.DEFINITION_ID: _check_definition, - Check.LOG_ID: _check_log, - Check.THREAD_ID: _check_thread, - Check.VISIBLE_ONLY: _check_visible_only, -} - - -def logged_in(func): - def wrapper(*args, **kwargs): - user = _get_user(*args, **kwargs) - if not user.is_anonymous: - return func(*args, **kwargs) - raise AuthenticationFailed("Authentication failed") - - return wrapper - - -def protected(required_group: User.AuthGroup): - """ - Decorator that checks whether user of request has required authorization group. - If not, PermissionDenied exception is raised. - - Args: - required_group: minimal required authorization group to access endpoint - """ - - def decorator(func): - def wrapper(*args, **kwargs): - action_name = _get_action_name(func, *args) - log_text = f"action: {action_name} | arguments: {kwargs}" - - if settings.NOAUTH: - if action_name is not None: - logger.info(log_text) - return func(*args, **kwargs) - - user = _get_user(*args, **kwargs) - if action_name is not None: - log_text += f" | username: {user.username}" - logger.info(log_text) - - if user.is_anonymous: - raise AuthenticationFailed("Authentication failed") - - if user.group >= required_group: - return func(*args, **kwargs) - raise PermissionDenied("Permission denied") - - return wrapper - - return decorator - - -def _input_object_checks( - user: User, param_id: int, input_obj: InputObject -) -> bool: - """ - Helper function for checking authorization restrications on input objects. - Returns: - True if one of the condition to access the data is met otherwise False. - """ - if isinstance(input_obj, (UseToolInput, SelectTeamInjectInput)): - if isinstance(input_obj, UseToolInput): - exercise = get_model(Exercise, teams__in=[param_id]) - # check if user has access to given tool (is in exercise - # containing the tool) - if not exercise.definition.tools.filter( - id=input_obj.tool_id - ).exists(): - return False - return _check_team(user, param_id) - - elif isinstance(input_obj, SendEmailInput): - if param_id == INSTRUCTOR: - return user.is_staff - return _check_team(user, param_id) - - elif isinstance(input_obj, CreateExerciseInput): - return _check_definition(user, param_id) - return False - - -def input_object_protected(obj_name: str): - """ - Decorator for checking InputObject types evaluates whether user has access - to requested data based on the data from InputObject type. - Input objects MUST HAVE parmater with name given in "obj_name" parameter to - work properly. - Args: - obj_name: name of keyword argument of the input object of the resolver - """ - - def decorator(func): - def wrapper(*args, **kwargs): - action_name = _get_action_name(func, *args) - log_text = f"action: {action_name} | arguments: {kwargs}" - if settings.NOAUTH: - if action_name is not None: - logger.info(log_text) - return func(*args, **kwargs) - - user = _get_user(*args, **kwargs) - - if action_name is not None: - log_text += f" | username: {user.username}" - logger.info(log_text) - - if user.is_anonymous: - raise AuthenticationFailed("Authentication failed") - - if user.is_superuser: - return func(*args, **kwargs) - - input_obj = kwargs.get(obj_name, None) - if input_obj is None: - raise PermissionDenied("Permission denied") - - # create_exercise_input: definition_id - # other input objects: team_id - param_id = _get_param_id_from_input_obj(input_obj, *args, **kwargs) - - if _input_object_checks(user, param_id, input_obj): - return func(*args, **kwargs) - raise PermissionDenied("Permission denied") - - return wrapper - - return decorator - - -def extra_protected(check: Check): - def decorator(func): - def wrapper(*args, **kwargs): - action_name = _get_action_name(func, *args) - log_text = f"action: {action_name} | arguments: {kwargs}" - - if settings.NOAUTH: - if action_name is not None: - logger.info(log_text) - return func(*args, **kwargs) - - check_function = CHECK_MAPPING[check] - user = _get_user(*args, **kwargs) - - if action_name is not None: - log_text += f" | username: {user.username}" - logger.info(log_text) - - if user.is_anonymous: - raise AuthenticationFailed("Authentication failed") - - param_id: Optional[int] = kwargs.get(check.value, None) - if param_id is None: - raise PermissionDenied("Permission denied") - - if user.is_superuser or check_function(user, int(param_id)): - return func(*args, **kwargs) - raise PermissionDenied("Permission denied") - - return wrapper - - return decorator diff --git a/common_lib/graphql/__init__.py b/common_lib/graphql/__init__.py index 5e2cd30e6434a3d61d2cf738f7e1d88862eb3c93..4b320faa81e778d4a386d152c977423734e2212a 100644 --- a/common_lib/graphql/__init__.py +++ b/common_lib/graphql/__init__.py @@ -3,6 +3,7 @@ from .queries import ( EmailContactsAction, ExerciseIdAction, EmailThreadsAction, + EmailThreadAction, TeamActionLogsAction, ExtendedTeamToolsAction, ExerciseConfigAction, @@ -13,7 +14,6 @@ from .mutations import ( CreateExerciseAction, CreateThreadAction, UseToolAction, - SelectTeamInjectAction, StartExerciseAction, DeleteExerciseAction, StopExerciseAction, diff --git a/common_lib/graphql/mutations.py b/common_lib/graphql/mutations.py index 8d405d9280bea35d7b62f9492052373f229d8d9c..2f8907cdd895d42ef19af7c1ca06d6e49b9c4241 100644 --- a/common_lib/graphql/mutations.py +++ b/common_lib/graphql/mutations.py @@ -12,7 +12,6 @@ from common_lib.utils import create_input from exercise.graphql_inputs import CreateExerciseInput, ConfigOverrideInput from running_exercise.graphql_inputs import ( UseToolInput, - SelectTeamInjectInput, SendEmailInput, CustomInjectInput, OverlayInput, @@ -80,25 +79,6 @@ class SendEmailAction(APIAction): create_input(SendEmailInput, **self.variables) -class SelectTeamInjectAction(APIAction): - query_name = "selectTeamInject" - - def __init__(self, variables: Dict[str, Any]): - self.variables = variables - self.query = GraphQLQuery( - "mutation", - self.query_name, - [("selectTeamInjectInput", "SelectTeamInjectInput!")], - Fragments.operation_done(), - ) - - def get_variables(self) -> Dict[str, Any]: - return {"selectTeamInjectInput": self.variables} - - def validate(self) -> None: - create_input(SelectTeamInjectInput, **self.variables) - - class CreateExerciseAction(APIAction): query_name = "createExercise" diff --git a/common_lib/graphql/queries.py b/common_lib/graphql/queries.py index 18a71397407231b94d0cb8b67458c6ee8ae7eebb..f72d84692daaf20f250f7e0b0b25669909bd6575 100644 --- a/common_lib/graphql/queries.py +++ b/common_lib/graphql/queries.py @@ -40,6 +40,19 @@ class EmailThreadsAction(APIAction): ) +class EmailThreadAction(APIAction): + query_name = "emailThread" + + def __init__(self, variables: Dict[str, Any]): + self.variables = variables + self.query = GraphQLQuery( + "query", + self.query_name, + [id_argument("thread")], + Fragments.email_thread(), + ) + + class EmailContactsAction(APIAction): query_name = "emailContacts" diff --git a/common_lib/subscription_handler.py b/common_lib/subscription_handler.py index 7b40692acdaf53944681cda6a9a0bb04eda65e29..73d7570206ac4499deef3e70f31ad8dd2b2dac06 100644 --- a/common_lib/subscription_handler.py +++ b/common_lib/subscription_handler.py @@ -79,8 +79,7 @@ class SubscriptionHandler: milestone_states, team.exercise ) - group = "all" if team is None else str(team.id) - cls._milestones.broadcast(payload=milestone_states, group=group) + cls._milestones.broadcast(payload=milestone_states, group=str(team.id)) @classmethod def broadcast_team_visible_milestones( diff --git a/exercise/schema/mutation.py b/exercise/schema/mutation.py index ba2f8e2cc5e5740fe38b7c842700ed8858776d78..73034a790e7358f89dcdf36179009476d7a79754 100644 --- a/exercise/schema/mutation.py +++ b/exercise/schema/mutation.py @@ -1,14 +1,14 @@ import graphene - from django.conf import settings +from aai.access import exercise_access, definition_access +from aai.decorators import protected from common_lib.schema_types import ExerciseType from exercise.graphql_inputs import CreateExerciseInput from exercise.lib.exercise_manager import ExerciseManager from exercise_definition.lib.definition_manager import DefinitionManager -from aai.utils import protected, extra_protected, input_object_protected, Check -from user.schema.validators import validate_instructor_assigning from user.models import InstructorOfExercise, User +from user.schema.validators import validate_instructor_assigning class CreateExerciseMutation(graphene.Mutation): @@ -21,13 +21,15 @@ class CreateExerciseMutation(graphene.Mutation): @classmethod @protected(User.AuthGroup.INSTRUCTOR) - @input_object_protected("create_exercise_input") def mutate( cls, root, info, create_exercise_input: CreateExerciseInput, ) -> graphene.Mutation: + definition_access( + info.context, int(create_exercise_input.definition_id) + ) user = info.context.user exercise = ExerciseManager.create_exercise( create_exercise_input, info.context @@ -50,8 +52,8 @@ class DeleteExerciseMutation(graphene.Mutation): @classmethod @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.EXERCISE_ID) def mutate(cls, root, info, exercise_id: str): + exercise_access(info.context, int(exercise_id)) ExerciseManager.delete_exercise(int(exercise_id)) return DeleteExerciseMutation(operation_done=True) @@ -64,8 +66,8 @@ class DeleteDefinitionMutation(graphene.Mutation): @classmethod @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.DEFINITION_ID) def mutate(cls, root, info, definition_id: str): + definition_access(info.context, int(definition_id)) DefinitionManager.delete_definition(int(definition_id)) return DeleteDefinitionMutation(operation_done=True) diff --git a/exercise/schema/query.py b/exercise/schema/query.py index 31d36ce8f8c52c99ebd61989ad7a45c0fd0bfa22..01db958707cafb72d570d578149a69492f8fa684 100644 --- a/exercise/schema/query.py +++ b/exercise/schema/query.py @@ -3,7 +3,8 @@ from typing import Optional, List import graphene from django.conf import settings -from aai.utils import protected, extra_protected, Check +from aai.access import team_access, exercise_access, definition_access +from aai.decorators import protected from common_lib.schema_types import ( ExerciseType, DefinitionType, @@ -132,8 +133,8 @@ class Query(graphene.ObjectType): return exercises @protected(User.AuthGroup.TRAINEE) - @extra_protected(Check.EXERCISE_ID) def resolve_exercise_id(self, info, exercise_id: str) -> Optional[Exercise]: + exercise_access(info.context, int(exercise_id)) return Exercise.objects.filter(id=exercise_id).first() @protected(User.AuthGroup.INSTRUCTOR) @@ -150,18 +151,18 @@ class Query(graphene.ObjectType): return [] @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.DEFINITION_ID) def resolve_definition(self, info, definition_id: str) -> Definition: + definition_access(info.context, int(definition_id)) return get_model(Definition, id=definition_id) @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.EXERCISE_ID) def resolve_injects(self, info, exercise_id: str) -> List[Inject]: + exercise_access(info.context, int(exercise_id)) return Inject.objects.filter(definition__exercises__in=[exercise_id]) @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.EXERCISE_ID) def resolve_milestones(self, info, exercise_id: str) -> List[Milestone]: + exercise_access(info.context, int(exercise_id)) exercise = get_model( Exercise, id=int(exercise_id), @@ -174,17 +175,18 @@ class Query(graphene.ObjectType): return get_model(FileInfo, id=file_info_id) @protected(User.AuthGroup.TRAINEE) - @extra_protected(Check.TEAM_ID) def resolve_team_uploaded_files(self, info, team_id: str) -> List[FileInfo]: + team_access(info.context, int(team_id)) return FileInfo.objects.filter(team__team_id=team_id) @protected(User.AuthGroup.TRAINEE) def resolve_channel(self, info, channel_id: str) -> Channel: + # TODO: Add resolving of channel based on exercise_id (including access check) return get_model(Channel, id=channel_id) @protected(User.AuthGroup.TRAINEE) - @extra_protected(Check.EXERCISE_ID) def resolve_exercise_channels( self, info, exercise_id: str ) -> List[Channel]: + exercise_access(info.context, int(exercise_id)) return Channel.objects.filter(definition__exercises__in=[exercise_id]) diff --git a/exercise/subscription.py b/exercise/subscription.py index 9f7c91c016b2c7c2fb21175ab65a99e04b69fb67..05f4a0775eac9923e65be85f0fb0b55a4953b60b 100644 --- a/exercise/subscription.py +++ b/exercise/subscription.py @@ -1,10 +1,10 @@ import channels_graphql_ws import graphene -from common_lib.schema_types import ExerciseEventTypeEnum +from aai.decorators import protected +from common_lib.schema_types import ExerciseEventTypeEnum from common_lib.schema_types import ExerciseType from user.models import User -from aai.utils import protected NOTIFICATION_QUEUE_LIMIT = 64 diff --git a/exercise/views.py b/exercise/views.py index a2f6354a4c792a5ed7e0b05c744d14da74a01075..321a949cb840b3f5794797734fda70267c813c8b 100644 --- a/exercise/views.py +++ b/exercise/views.py @@ -6,25 +6,27 @@ from rest_framework import parsers from rest_framework.response import Response from rest_framework.views import APIView -from user.models import User -from aai.utils import protected, extra_protected, Check +from aai.access import exercise_access +from aai.decorators import protected from common_lib.exceptions import ApiException from exercise.lib.export_import import export_database, import_database from exercise.lib.log_manager import LogManager +from user.models import User class RetrieveExerciseLogsView(APIView): @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.EXERCISE_ID) def get(self, request, *args, **kwargs): """ Get logs of all teams in a game. """ - exercise_id = self.kwargs.get("exercise_id") + exercise_id = int(self.kwargs.get("exercise_id")) anonymize = request.GET.get("anonymize") is not None force = request.GET.get("force") is not None + + exercise_access(request, exercise_id) archive_path = LogManager( - exercise_id=int(exercise_id), anonymize=anonymize + exercise_id=exercise_id, anonymize=anonymize ).create_exercise_logs(force) return FileResponse(open(archive_path, "rb"), as_attachment=True) diff --git a/exercise_definition/views.py b/exercise_definition/views.py index a230998f5fbf25b888ae9844da3db4582ee688bd..5f85ece025c696a9bc01d24e41c8246c8b294083 100644 --- a/exercise_definition/views.py +++ b/exercise_definition/views.py @@ -6,7 +6,7 @@ from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView -from aai.utils import protected +from aai.decorators import protected from common_lib.exceptions import ApiException from common_lib.logger import logger, log_user_msg from exercise_definition.lib import DefinitionUploader, DefinitionParser diff --git a/running_exercise/graphql_inputs.py b/running_exercise/graphql_inputs.py index f523b178777c19eec392424fe40e0f2860672ff0..164886b4f78c791fb1f635e48e7529850a6fa157 100644 --- a/running_exercise/graphql_inputs.py +++ b/running_exercise/graphql_inputs.py @@ -19,27 +19,6 @@ class UseToolInput(UseToolBase, graphene.InputObjectType): pass -class SelectTeamInjectBase: - team_id = graphene.ID(required=True) - selection_id = graphene.ID(required=True) - option_email = graphene.Boolean(required=True) - content = graphene.String(required=True) - activate_milestone = graphene.String(required=False, default_value="") - deactivate_milestone = graphene.String(required=False, default_value="") - file_id = graphene.UUID(required=False, default_value="") - sender = graphene.String(required=False, default_value="") - subject = graphene.String(required=False, default_value="") - repeat = graphene.Int(required=False, default_value=0) - - -class SelectTeamInjectType(SelectTeamInjectBase, graphene.ObjectType): - pass - - -class SelectTeamInjectInput(SelectTeamInjectBase, graphene.InputObjectType): - pass - - class OverlayInput(graphene.InputObjectType): duration = graphene.Int(required=True) diff --git a/running_exercise/lib/email_client.py b/running_exercise/lib/email_client.py index 6311ab467312159a9267454a9bedd8383045390e..7bf5392a9abd7d23a85b0d55c924817a3cf39499 100644 --- a/running_exercise/lib/email_client.py +++ b/running_exercise/lib/email_client.py @@ -252,26 +252,6 @@ class EmailClient: templates.extend(email_address.templates.all()) return templates - @staticmethod - def get_thread_templates(thread_id: int) -> List[EmailTemplate]: - thread = get_model(EmailThread, id=thread_id) - definition_participant = thread.get_definition_participant() - if definition_participant is None: - return [] - - return list( - definition_participant.get_definition_address().templates.all() - ) - - @staticmethod - def get_instructor_email_addresses(thread_id: int) -> List[str]: - thread = get_model(EmailThread, id=thread_id) - return list( - thread.participants.filter(team_id__isnull=True).values_list( - "address", flat=True - ) - ) - @staticmethod def validate_email_address(exercise_id: int, address: str) -> bool: return EmailParticipant.objects.filter( diff --git a/running_exercise/lib/loop_thread.py b/running_exercise/lib/loop_thread.py index 3cf68e90294aa6f398f039359ff8406f23b79c5c..4d19297d173580d58fe7800271692dbcaf227849 100644 --- a/running_exercise/lib/loop_thread.py +++ b/running_exercise/lib/loop_thread.py @@ -71,7 +71,8 @@ class LoopThread(Thread): ) finally: SubscriptionHandler.broadcast_exercises( - self.updater.exercise, ExerciseEventTypeEnum.modify() + get_model(Exercise, id=self.updater.exercise.id), + ExerciseEventTypeEnum.modify(), ) SubscriptionHandler.broadcast_exercise_loop( self.updater.exercise, False diff --git a/running_exercise/lib/team_action_handler.py b/running_exercise/lib/team_action_handler.py index b1a2fa9d3800616565a808cf6a3e5964be87d397..50f24dac5c43872283b0308bc3a9ca42be30e1e0 100644 --- a/running_exercise/lib/team_action_handler.py +++ b/running_exercise/lib/team_action_handler.py @@ -30,6 +30,7 @@ from user.models import User def _parse_selected_tool(team: Team, tool_id: int) -> Tool: + # TODO: check if tool is in the exercise tool = get_model(Tool, id=tool_id) if not has_role(team.role, tool.roles.split(" ")): diff --git a/running_exercise/schema/mutation.py b/running_exercise/schema/mutation.py index eb86b1268c0aca6d54b35e72250d8bc4ba24b523..f6059ddfe3f3bcc235b8545c4a3422d672e6d351 100644 --- a/running_exercise/schema/mutation.py +++ b/running_exercise/schema/mutation.py @@ -1,11 +1,14 @@ from typing import List import graphene -from django.conf import settings +from rest_framework.exceptions import PermissionDenied -from aai.utils import protected, extra_protected, input_object_protected, Check +from aai.access import user_from_context, team_access, exercise_access +from aai.decorators import protected from common_lib.logger import logger from common_lib.schema_types import ExerciseType, EmailThreadType +from common_lib.utils import get_model +from exercise.models import EmailParticipant from running_exercise.graphql_inputs import ( UseToolInput, SendEmailInput, @@ -19,6 +22,7 @@ from running_exercise.lib.instructor_action_handler import ( ) from running_exercise.lib.milestone_handler import instructor_modify_milestone from running_exercise.lib.team_action_handler import TeamActionHandler +from running_exercise.models import EmailThread from running_exercise.questionnaire_handler import QuestionnaireHandler from user.models import User @@ -31,15 +35,17 @@ class UseToolMutation(graphene.Mutation): @classmethod @protected(User.AuthGroup.TRAINEE) - @input_object_protected("use_tool_input") def mutate( cls, root, info, use_tool_input: UseToolInput, ) -> graphene.Mutation: - user = None if settings.NOAUTH else info.context.user - TeamActionHandler.perform_action(use_tool_input, user) + team_access(info.context, int(use_tool_input.team_id)) + + TeamActionHandler.perform_action( + use_tool_input, user_from_context(info.context) + ) return UseToolMutation(operation_done=True) @@ -59,7 +65,6 @@ class CreateThreadMutation(graphene.Mutation): @classmethod @protected(User.AuthGroup.TRAINEE) - @extra_protected(Check.EXERCISE_ID) def mutate( cls, root, @@ -68,6 +73,7 @@ class CreateThreadMutation(graphene.Mutation): subject: str, exercise_id: str, ): + exercise_access(info.context, int(exercise_id)) return CreateThreadMutation( thread=EmailClient.create_thread( participant_addresses, subject, exercise_id @@ -83,14 +89,30 @@ class SendEmailMutation(graphene.Mutation): @classmethod @protected(User.AuthGroup.TRAINEE) - @input_object_protected("send_email_input") def mutate( cls, root, info, send_email_input: SendEmailInput, ): - user = None if settings.NOAUTH else info.context.user + thread = get_model(EmailThread, id=send_email_input.thread_id) + participant = get_model( + EmailParticipant, + address=send_email_input.sender_address, + exercise_id=thread.exercise_id, + ) + user = user_from_context(info.context) + if user is None: + # should never happen + raise PermissionDenied("Unauthorized access") + if participant.is_definition(): + if user.group == User.AuthGroup.TRAINEE: + raise PermissionDenied( + f"User cannot send email as this address ({send_email_input.sender_address})" + ) + exercise_access(info.context, thread.exercise_id) + else: + team_access(info.context, participant.team_id) EmailClient.send_email(send_email_input, user) return SendEmailMutation(operation_done=True) @@ -104,8 +126,8 @@ class MoveExerciseTimeMutation(graphene.Mutation): @classmethod @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.EXERCISE_ID) def mutate(cls, root, info, exercise_id: str, time_diff: int): + exercise_access(info.context, int(exercise_id)) exercise = ExerciseLoop.move_time(int(exercise_id), time_diff) return MoveExerciseTimeMutation(exercise=exercise) @@ -118,8 +140,8 @@ class StartExerciseMutation(graphene.Mutation): @classmethod @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.EXERCISE_ID) def mutate(cls, root, info, exercise_id: str): + exercise_access(info.context, int(exercise_id)) exercise = ExerciseLoop.start(int(exercise_id)) return StartExerciseMutation(exercise=exercise) @@ -132,8 +154,8 @@ class StopExerciseMutation(graphene.Mutation): @classmethod @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.EXERCISE_ID) def mutate(cls, root, info, exercise_id: str): + exercise_access(info.context, int(exercise_id)) exercise = ExerciseLoop.stop(int(exercise_id)) return StopExerciseMutation(exercise=exercise) @@ -152,8 +174,8 @@ class ModifyMilestoneMutation(graphene.Mutation): @classmethod @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.TEAM_ID) def mutate(cls, root, info, team_id: str, milestone: str, activate: bool): + team_access(info.context, int(team_id)) instructor_modify_milestone( team_id=int(team_id), milestone=milestone, activate=activate ) @@ -172,6 +194,7 @@ class AnswerQuestionnaireMutation(graphene.Mutation): @classmethod @protected(User.AuthGroup.TRAINEE) def mutate(cls, root, info, quest_input: QuestionnaireInput): + team_access(info.context, int(quest_input.team_id)) QuestionnaireHandler.answer_questionnaire(quest_input) return AnswerQuestionnaireMutation(operation_done=True) @@ -187,7 +210,8 @@ class SendCustomInjectMutation(graphene.Mutation): @classmethod @protected(User.AuthGroup.INSTRUCTOR) def mutate(cls, root, info, custom_inject_input: CustomInjectInput): - user = None if settings.NOAUTH else info.context.user + exercise_access(info.context, int(custom_inject_input.exercise_id)) + user = user_from_context(info.context) InstructorActionHandler.send_custom_inject(custom_inject_input, user) return SendCustomInjectMutation(operation_done=True) diff --git a/running_exercise/schema/query.py b/running_exercise/schema/query.py index 82efe25eb5cfe614c0edfeb907b4322181acfae8..f4609cacc8b6110ab99f32d92fabcc40b15dbc06 100644 --- a/running_exercise/schema/query.py +++ b/running_exercise/schema/query.py @@ -2,9 +2,10 @@ from typing import List import graphene from django.db.models import QuerySet +from rest_framework.exceptions import PermissionDenied -from user.models import User -from aai.utils import protected, extra_protected, Check +from aai.access import team_access, exercise_access, user_from_context +from aai.decorators import protected from common_lib.exceptions import RunningExerciseOperationException from common_lib.schema_types import ( ActionLogType, @@ -39,6 +40,7 @@ from running_exercise.lib.exercise_loop import ExerciseLoop from running_exercise.lib.milestone_handler import get_milestone_states from running_exercise.lib.utils import get_running_exercise from running_exercise.models import ActionLog, EmailThread +from user.models import User class Query(graphene.ObjectType): @@ -201,8 +203,9 @@ class Query(graphene.ObjectType): ) @protected(User.AuthGroup.TRAINEE) - @extra_protected(Check.TEAM_ID) def resolve_team(self, info, team_id: str) -> Team: + team_access(info.context, int(team_id)) + return get_model(Team, id=int(team_id)) @protected(User.AuthGroup.TRAINEE) @@ -211,11 +214,12 @@ class Query(graphene.ObjectType): running_exercise = get_running_exercise() if running_exercise is None: raise RunningExerciseOperationException("No exercise is running.") + exercise_access(info.context, running_exercise.id) return [team.role for team in running_exercise.teams.all()] @protected(User.AuthGroup.TRAINEE) - @extra_protected(Check.TEAM_ID) def resolve_team_tools(self, info, team_id: str) -> List[Tool]: + team_access(info.context, int(team_id)) team = get_model(Team, id=int(team_id)) return [ @@ -225,8 +229,8 @@ class Query(graphene.ObjectType): ] @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.TEAM_ID) def resolve_extended_team_tools(self, info, team_id: str) -> List[Tool]: + team_access(info.context, int(team_id)) team = get_model(Team, id=int(team_id)) return [ @@ -236,90 +240,123 @@ class Query(graphene.ObjectType): ] @protected(User.AuthGroup.TRAINEE) - @extra_protected(Check.LOG_ID) def resolve_action_log(self, info, log_id: str) -> ActionLog: - return get_model(ActionLog, id=int(log_id)) + log = get_model(ActionLog, id=int(log_id)) + team_access(info.context, log.team_id) + return log @protected(User.AuthGroup.TRAINEE) - @extra_protected(Check.TEAM_ID) def resolve_team_action_logs( self, info, team_id: str ) -> QuerySet[ActionLog]: + team_access(info.context, int(team_id)) return ActionLog.objects.filter(team_id=team_id) + @protected(User.AuthGroup.TRAINEE) def resolve_team_channel_logs( self, info, team_id: str, channel_id: str ) -> QuerySet[ActionLog]: + team_access(info.context, int(team_id)) return ActionLog.objects.filter(team_id=team_id, channel_id=channel_id) @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.TEAM_ID) - @extra_protected(Check.VISIBLE_ONLY) def resolve_team_milestones( self, info, team_id: str, visible_only: bool = True ) -> List[MilestoneState]: + team_access(info.context, int(team_id)) return get_milestone_states(int(team_id), visible_only) @protected(User.AuthGroup.TRAINEE) - @extra_protected(Check.VISIBLE_ONLY) def resolve_email_contacts( self, info, visible_only: bool = True ) -> QuerySet[EmailParticipant]: + if ( + user := user_from_context(info.context) + ) and user.group == User.AuthGroup.TRAINEE: + return EmailClient.get_contacts(True) return EmailClient.get_contacts(visible_only) @protected(User.AuthGroup.TRAINEE) def resolve_email_contact( self, info, participant_id: str ) -> EmailParticipant: - return get_model(EmailParticipant, id=int(participant_id)) + participant = get_model(EmailParticipant, id=int(participant_id)) + exercise_access(info.context, participant.exercise_id) + return participant @protected(User.AuthGroup.TRAINEE) - @extra_protected(Check.TEAM_ID) def resolve_email_threads( self, info, team_id: str ) -> QuerySet[EmailThread]: + team_access(info.context, int(team_id)) return EmailClient.get_threads(int(team_id)) @protected(User.AuthGroup.TRAINEE) - @extra_protected(Check.THREAD_ID) def resolve_email_thread(self, info, thread_id: str) -> EmailThread: - return get_model(EmailThread, id=int(thread_id)) + thread = get_model(EmailThread, id=int(thread_id)) + user = user_from_context(info.context) + exercise_access(info.context, thread.exercise_id) + # :) + if user is not None and user.group == User.AuthGroup.TRAINEE: + in_team = thread.participants.filter( + team__users__user_id__in=[user.id] + ).exists() + if not in_team: + raise PermissionDenied( + f"User does not have access to this email thread ({thread_id})" + ) + + return thread - @protected(User.AuthGroup.TRAINEE) - @extra_protected(Check.THREAD_ID) + @protected(User.AuthGroup.INSTRUCTOR) def resolve_email_addresses(self, info, thread_id: str) -> List[str]: - return EmailClient.get_instructor_email_addresses(int(thread_id)) + thread = get_model(EmailThread, id=thread_id) + exercise_access(info.context, thread.exercise_id) + return list( + thread.participants.filter(team_id__isnull=True).values_list( + "address", flat=True + ) + ) @protected(User.AuthGroup.TRAINEE) - @extra_protected(Check.EXERCISE_ID) def resolve_validate_email_address( self, info, exercise_id: str, address: str ) -> bool: + exercise_access(info.context, int(exercise_id)) return EmailClient.validate_email_address(int(exercise_id), address) @protected(User.AuthGroup.TRAINEE) - @extra_protected(Check.TEAM_ID) def resolve_team_email_participant( self, info, team_id: str ) -> EmailParticipant: + team_access(info.context, int(team_id)) return EmailClient.get_team_participant(int(team_id)) @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.EXERCISE_ID) def resolve_email_templates( self, info, exercise_id: str, email_addresses: List[str] ) -> List[EmailTemplate]: + exercise_access(info.context, int(exercise_id)) return EmailClient.get_email_templates(exercise_id, email_addresses) @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.THREAD_ID) def resolve_thread_templates( self, info, thread_id: int ) -> List[EmailTemplate]: - return EmailClient.get_thread_templates(int(thread_id)) + thread = get_model(EmailThread, id=thread_id) + exercise_access(info.context, thread.exercise_id) + + definition_participant = thread.get_definition_participant() + if definition_participant is None: + return [] + + return list( + definition_participant.get_definition_address().templates.all() + ) @protected(User.AuthGroup.INSTRUCTOR) def resolve_thread_template(self, info, template_id: str) -> EmailTemplate: + # TODO: not secured return get_model(EmailTemplate, id=int(template_id)) @protected(User.AuthGroup.TRAINEE) @@ -336,8 +373,8 @@ class Query(graphene.ObjectType): return max(0, exercise_duration_s - int(running_exercise.elapsed_s)) @protected(User.AuthGroup.TRAINEE) - @extra_protected(Check.EXERCISE_ID) def resolve_exercise_config(self, info, exercise_id: str) -> GrapheneConfig: + exercise_access(info.context, int(exercise_id)) exercise = get_model(Exercise, id=int(exercise_id)) config: Config = exercise.config @@ -352,49 +389,49 @@ class Query(graphene.ObjectType): ) @protected(User.AuthGroup.TRAINEE) - @extra_protected(Check.EXERCISE_ID) def resolve_exercise_loop_running(self, info, exercise_id: str) -> bool: + exercise_access(info.context, int(exercise_id)) return ExerciseLoop.is_running(int(exercise_id)) @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.EXERCISE_ID) def resolve_analytics_milestones( self, info, exercise_id: str ) -> QuerySet[MilestoneState]: + exercise_access(info.context, int(exercise_id)) return MilestoneState.objects.filter( team_state__exercise_id=int(exercise_id) ) @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.EXERCISE_ID) def resolve_analytics_action_logs( self, info, exercise_id: str ) -> QuerySet[ActionLog]: + exercise_access(info.context, int(exercise_id)) exercise = get_model(Exercise, id=int(exercise_id)) return ActionLog.objects.filter(team__in=exercise.teams.all()) @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.EXERCISE_ID) def resolve_analytics_email_threads( self, info, exercise_id: str ) -> QuerySet[EmailThread]: + exercise_access(info.context, int(exercise_id)) return EmailThread.objects.filter( exercise_id=exercise_id ).prefetch_related("emails") @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.EXERCISE_ID) def resolve_exercise_tools(self, info, exercise_id: str) -> List[Tool]: + exercise_access(info.context, int(exercise_id)) exercise = get_model(Exercise, id=int(exercise_id)) return exercise.definition.tools.all() @protected(User.AuthGroup.TRAINEE) - @extra_protected(Check.TEAM_ID) def resolve_questionnaire_state( self, info, team_id: str, questionnaire_id: str ) -> TeamQuestionnaireState: + team_access(info.context, int(team_id)) return get_model( TeamQuestionnaireState, team_id=team_id, @@ -402,17 +439,17 @@ class Query(graphene.ObjectType): ) @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.TEAM_ID) def resolve_team_questionnaires( self, info, team_id: str ) -> List[TeamQuestionnaireState]: + team_access(info.context, int(team_id)) return TeamQuestionnaireState.objects.filter(team_id=team_id) @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.EXERCISE_ID) def resolve_exercise_questionnaires( self, info, exercise_id: str ) -> List[TeamQuestionnaireState]: + exercise_access(info.context, int(exercise_id)) return TeamQuestionnaireState.objects.filter( team__exercise_id=exercise_id ) @@ -421,6 +458,7 @@ class Query(graphene.ObjectType): def resolve_team_learning_objectives( self, info, team_id: str ) -> List[TeamLearningObjective]: + team_access(info.context, int(team_id)) return TeamLearningObjective.objects.filter( team_state__teams__in=[team_id] ).prefetch_related("objective", "activities") diff --git a/running_exercise/subscription.py b/running_exercise/subscription.py index 68f1078dd62b2e7edd8f7a9992fb4f59007b0089..44a1584a8c1e34993ea6179b0f670ba891efcbb0 100644 --- a/running_exercise/subscription.py +++ b/running_exercise/subscription.py @@ -1,8 +1,8 @@ import channels_graphql_ws import graphene -from user.models import User -from aai.utils import protected, extra_protected, Check +from aai.access import exercise_access, team_access +from aai.decorators import protected from common_lib.schema_types import ( ActionLogType, MilestoneStateType, @@ -11,6 +11,7 @@ from common_lib.schema_types import ( ) from common_lib.utils import get_model, get_subscription_group from exercise.models import Team, Exercise +from user.models import User NOTIFICATION_QUEUE_LIMIT = 64 @@ -25,8 +26,8 @@ class ExerciseLoopRunningSubscription(channels_graphql_ws.Subscription): @staticmethod @protected(User.AuthGroup.TRAINEE) - @extra_protected(Check.EXERCISE_ID) def subscribe(root, info, exercise_id: str): + exercise_access(info.context, int(exercise_id)) get_model(Exercise, id=int(exercise_id)) return [exercise_id] @@ -45,8 +46,8 @@ class ActionLogsSubscription(channels_graphql_ws.Subscription): @staticmethod @protected(User.AuthGroup.TRAINEE) - @extra_protected(Check.TEAM_ID) def subscribe(root, info, team_id: str): + team_access(info.context, int(team_id)) get_model(Team, id=int(team_id)) return [team_id] @@ -69,11 +70,10 @@ class MilestonesSubscription(channels_graphql_ws.Subscription): @staticmethod @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.TEAM_ID) - @extra_protected(Check.VISIBLE_ONLY) def subscribe(root, info, team_id: str, visible_only: bool = True): + team_access(info.context, int(team_id)) get_model(Team, id=int(team_id)) - return ["all", get_subscription_group(int(team_id), visible_only)] + return [get_subscription_group(int(team_id), visible_only)] @staticmethod def publish(payload, info, team_id: str, visible_only: bool = True): @@ -89,8 +89,8 @@ class EmailThreadSubscription(channels_graphql_ws.Subscription): @staticmethod @protected(User.AuthGroup.TRAINEE) - @extra_protected(Check.TEAM_ID) def subscribe(root, info, team_id: str): + team_access(info.context, int(team_id)) get_model(Team, id=int(team_id)) return [team_id] @@ -108,8 +108,8 @@ class AnalyticsMilestonesSubscription(channels_graphql_ws.Subscription): @staticmethod @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.EXERCISE_ID) def subscribe(root, info, exercise_id: str): + exercise_access(info.context, int(exercise_id)) get_model(Exercise, id=int(exercise_id)) return [exercise_id] @@ -127,8 +127,8 @@ class AnalyticsActionLogsSubscription(channels_graphql_ws.Subscription): @staticmethod @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.EXERCISE_ID) def subscribe(root, info, exercise_id: str): + exercise_access(info.context, int(exercise_id)) get_model(Exercise, id=int(exercise_id)) return [exercise_id] @@ -146,8 +146,8 @@ class AnalyticsEmailThreadSubscription(channels_graphql_ws.Subscription): @staticmethod @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.EXERCISE_ID) def subscribe(root, info, exercise_id: str): + exercise_access(info.context, int(exercise_id)) get_model(Exercise, id=int(exercise_id)) return [exercise_id] @@ -167,8 +167,8 @@ class TeamQuestionnaireStateSubscription(channels_graphql_ws.Subscription): @staticmethod @protected(User.AuthGroup.TRAINEE) - @extra_protected(Check.TEAM_ID) def subscribe(root, info, team_id: str): + team_access(info.context, int(team_id)) get_model(Team, id=int(team_id)) return [team_id] diff --git a/running_exercise/views.py b/running_exercise/views.py index 230321bfca72757c05810ad7927201d6cfbb0167..0ce464b5174b400a9739c779990904465aec0314 100644 --- a/running_exercise/views.py +++ b/running_exercise/views.py @@ -3,18 +3,18 @@ from rest_framework import parsers from rest_framework.response import Response from rest_framework.views import APIView -from user.models import User -from aai.utils import protected, extra_protected, Check +from aai.access import team_access +from aai.decorators import protected from common_lib.exceptions import ApiException from running_exercise.lib.file_handler import ( upload_file, get_uploaded_file, ) +from user.models import User class GetFileView(APIView): @protected(User.AuthGroup.TRAINEE) - @extra_protected(Check.TEAM_ID) def get(self, request, *args, **kwargs): """ Get the requested file. If the exercise is running and parameter 'instructor' is not specified, @@ -27,6 +27,7 @@ class GetFileView(APIView): if file_id is None or team_id is None: raise ApiException("Invalid request, missing required parameters") + team_access(request, team_id) opened_file, file_name = get_uploaded_file(team_id, file_id, instructor) # according to Django docs, the `opened_file` @@ -41,7 +42,6 @@ class UploadFileView(APIView): parser_classes = [parsers.MultiPartParser] @protected(User.AuthGroup.TRAINEE) - @extra_protected(Check.TEAM_ID) def post(self, request, *args, **kwargs): """Upload a file as a team.""" file = request.FILES.get("file") @@ -49,7 +49,8 @@ class UploadFileView(APIView): if file is None or team_id is None: raise ApiException("Invalid request, missing required parameters") - file_id = upload_file(file, team_id) + team_access(request, int(team_id)) + file_id = upload_file(file, int(team_id)) return Response( { diff --git a/ttxbackend/schema.py b/ttxbackend/schema.py index ec153b141cfb3f260663750e69c73ade75ced33c..2351197c7d15b7b015cc9d39a78ac00e2fd5983a 100644 --- a/ttxbackend/schema.py +++ b/ttxbackend/schema.py @@ -9,7 +9,6 @@ from exercise.schema import ( from exercise.subscription import Subscription as ExerciseSubscription from running_exercise.graphql_inputs import ( UseToolType, - SelectTeamInjectType, SendEmailType, CustomInjectType, ) @@ -43,7 +42,6 @@ schema = graphene.Schema( subscription=Subscription, types=[ UseToolType, - SelectTeamInjectType, SendEmailType, ConfigOverrideType, CreateExerciseType, diff --git a/user/schema/mutation.py b/user/schema/mutation.py index 3f186ffab666766dafb6976772403485f6a96220..f9447aafbf48847c2a8bf41a6f33606bb4aa9352 100644 --- a/user/schema/mutation.py +++ b/user/schema/mutation.py @@ -1,14 +1,16 @@ from typing import List import graphene -from django.utils.crypto import get_random_string from django.conf import settings +from django.utils.crypto import get_random_string -from aai.utils import protected, extra_protected, Check +from aai.access import team_access, exercise_access, definition_access +from aai.decorators import protected from common_lib.logger import logger, log_user_msg from common_lib.schema_types import UserType from common_lib.utils import get_model from exercise.models import Team, Exercise, Definition +from user.email.email_sender import send_credentials from user.graphql_inputs import ChangeUserInput from user.models import UserInTeam, InstructorOfExercise, DefinitionAccess, User from user.schema.validators import ( @@ -21,7 +23,6 @@ from user.schema.validators import ( validate_credentials_regeneration, execute_change_userdata, ) -from user.email.email_sender import send_credentials class AssignUsersToTeamMutation(graphene.Mutation): @@ -40,11 +41,14 @@ class AssignUsersToTeamMutation(graphene.Mutation): @classmethod @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.TEAM_ID) def mutate( cls, root, info, user_ids: List[str], team_id: str ) -> graphene.Mutation: + # TODO: allow assigning instructors and when assigning an instructor, + # create entry in InstructorOfExercise instead of UserInTeam team = get_model(Team, id=team_id) + exercise_access(info.context, team.exercise_id) + users = validate_team_assigning(user_ids, team) UserInTeam.objects.bulk_create( UserInTeam(user=user, team=team) for user in users @@ -73,10 +77,10 @@ class RemoveUsersFromTeamMutation(graphene.Mutation): @classmethod @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.TEAM_ID) def mutate( cls, root, info, user_ids: List[str], team_id: str ) -> graphene.Mutation: + team_access(info.context, int(team_id)) team = get_model(Team, id=team_id) users = validate_team_removing(user_ids, team) UserInTeam.objects.filter(user__in=users, team=team).delete() @@ -104,10 +108,10 @@ class AssignInstructorsToExercise(graphene.Mutation): @classmethod @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.EXERCISE_ID) def mutate( cls, root, info, user_ids: List[str], exercise_id: str ) -> graphene.Mutation: + exercise_access(info.context, int(exercise_id)) exercise = get_model(Exercise, id=exercise_id) users = validate_instructor_assigning(user_ids, exercise) InstructorOfExercise.objects.bulk_create( @@ -137,10 +141,10 @@ class RemoveInstructorsFromExerciseMutation(graphene.Mutation): @classmethod @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.EXERCISE_ID) def mutate( cls, root, info, user_ids: List[str], exercise_id: str ) -> graphene.Mutation: + exercise_access(info.context, int(exercise_id)) exercise = get_model(Exercise, id=exercise_id) users = validate_instructor_removing(user_ids, exercise) InstructorOfExercise.objects.filter( @@ -170,10 +174,10 @@ class AddDefinitionAccessMutation(graphene.Mutation): @classmethod @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.DEFINITION_ID) def mutate( cls, root, info, user_ids: List[str], definition_id: str ) -> graphene.Mutation: + definition_access(info.context, int(definition_id)) definition = get_model(Definition, id=int(definition_id)) users = validate_definition_access_assinging(user_ids) DefinitionAccess.objects.bulk_create( @@ -203,10 +207,10 @@ class RemoveDefinitionAccessMutation(graphene.Mutation): @classmethod @protected(User.AuthGroup.INSTRUCTOR) - @extra_protected(Check.DEFINITION_ID) def mutate( cls, root, info, user_ids: List[str], definition_id: str ) -> graphene.Mutation: + definition_access(info.context, int(definition_id)) definition = get_model(Definition, id=int(definition_id)) users = validate_definition_access_removing(user_ids, definition) DefinitionAccess.objects.filter( diff --git a/user/schema/query.py b/user/schema/query.py index 4cdffcc62e83bbb6676b94a75ed6621f6251c651..71fcf1e718a4f3094730da20e979283b754795de 100644 --- a/user/schema/query.py +++ b/user/schema/query.py @@ -2,7 +2,7 @@ from typing import Optional, List import graphene -from aai.utils import protected, logged_in +from aai.decorators import protected, logged_in from common_lib.schema_types import UserType, TagType from common_lib.utils import get_model from user.graphql_inputs import FilterUsersInput diff --git a/user/views.py b/user/views.py index 4af5ca88b045e7920c7b9622edb9fa9ce3d41369..7dab809c6e3d8608457f79c7b8d765c868b9dcd4 100644 --- a/user/views.py +++ b/user/views.py @@ -3,11 +3,11 @@ from rest_framework import parsers from rest_framework.request import Request from rest_framework.views import APIView -from user.models import User -from aai.utils import protected +from aai.decorators import protected from common_lib.exceptions import ApiException from common_lib.logger import logger, log_user_msg from user.lib import UserUploader +from user.models import User class UploadUserFile(APIView):