diff --git a/.env_sample b/.env_sample index 6bd01cfbd..02d7ea324 100644 --- a/.env_sample +++ b/.env_sample @@ -14,6 +14,10 @@ DJANGO_SETTINGS_MODULE=settings.develop ALLOWED_HOSTS=localhost,example.com SUBMISSIONS_API_URL=http://django:8000/api MAX_EXECUTION_TIME_LIMIT=600 # time limit for the default queue (in seconds) +# Optional: if set, only members of this group can create competitions. +# If empty, behavior falls back to the current default. +# If set to a non-existent group, competition creation is denied. +COMPETITION_CREATOR_GROUP= # Local domain definition DOMAIN_NAME=localhost:80 diff --git a/src/apps/api/permissions.py b/src/apps/api/permissions.py index 3426a41f0..30b767341 100644 --- a/src/apps/api/permissions.py +++ b/src/apps/api/permissions.py @@ -1,8 +1,47 @@ +from django.conf import settings +from django.contrib.auth.models import Group from rest_framework import permissions from profiles.models import Membership +def _get_competition_creator_group_name(): + return getattr(settings, "COMPETITION_CREATOR_GROUP", "").strip() + + +def is_creator_group_missing(): + group_name = _get_competition_creator_group_name() + + return bool(group_name) and (not Group.objects.filter(name=group_name).exists()) + + +def user_can_create_competition(user): + if not user or not user.is_authenticated: + return False + + group_name = _get_competition_creator_group_name() + if not group_name: + return True + + # if configured group is missing, deny creation. + if not Group.objects.filter(name=group_name).exists(): + return False + + if user.is_superuser: + return True + + return user.groups.filter(name=group_name).exists() + + +class IsCompetitionCreator(permissions.BasePermission): + def has_permission(self, request, view): + if is_creator_group_missing(): + self.message = ( + "Competition creation is disabled: configured COMPETITION_CREATOR_GROUP does not exist." + ) + return user_can_create_competition(request.user) + + class IsOrganizerOrCollaborator(permissions.BasePermission): def has_object_permission(self, request, view, obj): is_collab = request.user in obj.collaborators.all() if hasattr(obj, 'collaborators') else False diff --git a/src/apps/api/tests/test_competitions.py b/src/apps/api/tests/test_competitions.py index 2c96e78b0..a35c42a6c 100644 --- a/src/apps/api/tests/test_competitions.py +++ b/src/apps/api/tests/test_competitions.py @@ -4,15 +4,40 @@ from zipfile import ZipFile from io import StringIO, BytesIO from unittest import mock +from django.contrib.auth.models import Group +from django.test import override_settings from django.urls import reverse from rest_framework.test import APITestCase +from api.permissions import user_can_create_competition from api.serializers.competitions import CompetitionSerializer from competitions.models import CompetitionParticipant, Submission, Competition from factories import UserFactory, CompetitionFactory, CompetitionParticipantFactory, PhaseFactory, LeaderboardFactory, \ ColumnFactory, SubmissionFactory, SubmissionScoreFactory, TaskFactory +class CompetitionCreatePermissionTests(APITestCase): + @override_settings(COMPETITION_CREATOR_GROUP='') + def test_defaults_to_legacy_behavior_when_group_setting_is_empty(self): + user = UserFactory() + assert user_can_create_competition(user) is True + + @override_settings(COMPETITION_CREATOR_GROUP='competition_creators') + def test_denies_when_group_setting_points_to_missing_group(self): + user = UserFactory() + assert user_can_create_competition(user) is False + + @override_settings(COMPETITION_CREATOR_GROUP='competition_creators') + def test_requires_membership_when_group_exists(self): + group = Group.objects.create(name='competition_creators') + member = UserFactory() + non_member = UserFactory() + member.groups.add(group) + + assert user_can_create_competition(member) is True + assert user_can_create_competition(non_member) is False + + class CompetitionTests(APITestCase): def setUp(self): self.creator = UserFactory(username='creator', password='creator') diff --git a/src/apps/api/tests/test_datasets.py b/src/apps/api/tests/test_datasets.py index b116184d7..59cf1b968 100644 --- a/src/apps/api/tests/test_datasets.py +++ b/src/apps/api/tests/test_datasets.py @@ -1,8 +1,11 @@ from django.urls import reverse from faker import Factory +from django.contrib.auth.models import Group +from django.test import override_settings from django.test import TestCase from rest_framework.test import APITestCase from datasets.models import Data +from competitions.models import CompetitionCreationTaskStatus from factories import ( UserFactory, DataFactory, @@ -212,6 +215,42 @@ def test_download_nonexistent_dataset(self): self.assertEqual(response.status_code, 404) +class DatasetUploadCompletedPermissionTests(APITestCase): + def setUp(self): + self.user = UserFactory() + self.client.force_login(self.user) + self.dataset = DataFactory(created_by=self.user, type=Data.COMPETITION_BUNDLE) + self.url = f"/api/datasets/completed/{self.dataset.key}/" + + @override_settings(COMPETITION_CREATOR_GROUP='competition_creators') + @patch("competitions.tasks.unpack_competition.apply_async") + def test_upload_completed_forbidden_for_non_group_member_when_group_exists(self, mock_apply_async): + Group.objects.create(name='competition_creators') + + response = self.client.put(self.url) + + self.assertEqual(response.status_code, 403) + self.assertEqual( + response.data["detail"], + "You do not have permission to create competitions" + ) + self.assertEqual(CompetitionCreationTaskStatus.objects.count(), 0) + mock_apply_async.assert_not_called() + + @override_settings(COMPETITION_CREATOR_GROUP='competition_creators') + @patch("competitions.tasks.unpack_competition.apply_async") + def test_upload_completed_forbidden_when_group_is_missing(self, mock_apply_async): + response = self.client.put(self.url) + + self.assertEqual(response.status_code, 403) + self.assertEqual( + response.data["detail"], + "Competition creation is disabled: configured COMPETITION_CREATOR_GROUP does not exist." + ) + self.assertEqual(CompetitionCreationTaskStatus.objects.count(), 0) + mock_apply_async.assert_not_called() + + class DatasetCreateTests(APITestCase): def setUp(self): self.user = UserFactory(username='creator', password='creator') diff --git a/src/apps/api/views/competitions.py b/src/apps/api/views/competitions.py index 09bd4c474..3c7590e4b 100644 --- a/src/apps/api/views/competitions.py +++ b/src/apps/api/views/competitions.py @@ -33,7 +33,7 @@ from competitions.utils import get_popular_competitions, get_recent_competitions from leaderboards.models import Leaderboard from utils.data import make_url_sassy -from api.permissions import IsOrganizerOrCollaborator +from api.permissions import IsOrganizerOrCollaborator, IsCompetitionCreator from django.db import transaction from django.conf import settings @@ -186,7 +186,7 @@ def get_permissions(self): if self.action in ['update', 'partial_update', 'destroy']: self.permission_classes = [IsOrganizerOrCollaborator] elif self.action in ['create']: - self.permission_classes = [IsAuthenticated] + self.permission_classes = [IsCompetitionCreator] elif self.action in ['retrieve', 'list']: self.permission_classes = [AllowAny] return [permission() for permission in self.permission_classes] diff --git a/src/apps/api/views/datasets.py b/src/apps/api/views/datasets.py index 0c95a8a3c..29eda795c 100644 --- a/src/apps/api/views/datasets.py +++ b/src/apps/api/views/datasets.py @@ -6,11 +6,13 @@ from django_filters.rest_framework import DjangoFilterBackend from rest_framework import status from rest_framework.decorators import api_view, action +from rest_framework.exceptions import PermissionDenied from rest_framework.filters import SearchFilter from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet from rest_framework.permissions import AllowAny +from api.permissions import user_can_create_competition, is_creator_group_missing from api.pagination import BasicPagination, LargePagination from api.serializers import datasets as serializers from datasets.models import Data @@ -266,6 +268,14 @@ def upload_completed(request, key): dataset.save() if dataset.type == Data.COMPETITION_BUNDLE: + if is_creator_group_missing(): + raise PermissionDenied( + "Competition creation is disabled: configured COMPETITION_CREATOR_GROUP does not exist." + ) + + if not user_can_create_competition(request.user): + raise PermissionDenied("You do not have permission to create competitions") + # Doing a local import here to avoid circular imports from competitions.tasks import unpack_competition diff --git a/src/settings/base.py b/src/settings/base.py index ed5b978d7..9ee29333a 100644 --- a/src/settings/base.py +++ b/src/settings/base.py @@ -161,6 +161,7 @@ # User Models AUTH_USER_MODEL = 'profiles.User' SOCIAL_AUTH_USER_MODEL = 'profiles.User' +COMPETITION_CREATOR_GROUP = os.environ.get('COMPETITION_CREATOR_GROUP', '').strip() # ============================================================================= # Debugging diff --git a/src/static/riot/competitions/management.tag b/src/static/riot/competitions/management.tag index 1c065fc76..5e837aaef 100644 --- a/src/static/riot/competitions/management.tag +++ b/src/static/riot/competitions/management.tag @@ -2,10 +2,10 @@