Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

su for contest admins #400

Merged
merged 13 commits into from
Sep 11, 2024
4 changes: 4 additions & 0 deletions oioioi/deployment/settings.py.template
Original file line number Diff line number Diff line change
Expand Up @@ -581,4 +581,8 @@ ZEUS_INSTANCES = {
# Experimental
# USE_ACE_EDITOR = False

# If set to True, contest admins will be able to log in as participants of contests they admin.
CONTEST_ADMINS_CAN_SU = False

# If set to False, contest admins switched to a participant will be able to make any type of request.
ALLOW_ONLY_GET_FOR_SU_CONTEST_ADMINS = True
4 changes: 4 additions & 0 deletions oioioi/su/README.rst
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
An option for admins to log in as other user
for testing purposes.

You can enable su for contest admins with ``CONTEST_ADMINS_CAN_SU`` option set to ``True`` in ``settings.py``. By default
this option is disabled. You can allow contest admins using su to make other request than `GET` by setting
``ALLOW_ONLY_GET_FOR_SU_CONTEST_ADMINS`` to ``True`` (default ``False``).
MasloMaslane marked this conversation as resolved.
Show resolved Hide resolved
10 changes: 10 additions & 0 deletions oioioi/su/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,13 @@

SU_UID_SESSION_KEY = 'su_effective_user_id'
SU_BACKEND_SESSION_KEY = 'su_effective_backend'
SU_REAL_USER_IS_SUPERUSER = 'su_real_user_is_superuser'
SU_ORIGINAL_CONTEST = 'su_original_contest'

BLOCKED_URLS = [
'api_token', 'api_regenerate_key',
'submitservice_view_user_token', 'submitservice_clear_user_token',
'edit_profile', 'delete_profile',
'auth_password_change', 'aut_password_done',
MasloMaslane marked this conversation as resolved.
Show resolved Hide resolved
]
BLOCKED_URL_NAMESPACES = ['two_factor', 'oioioiadmin']
50 changes: 48 additions & 2 deletions oioioi/su/middleware.py
A-dead-pixel marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
from django.core.exceptions import ImproperlyConfigured, PermissionDenied
from django.http.response import HttpResponseForbidden
from django.shortcuts import redirect
from django.conf import settings
from django.urls import resolve

from oioioi.base.utils.middleware import was_response_generated_by_exception
from oioioi.su import SU_BACKEND_SESSION_KEY, SU_UID_SESSION_KEY
from oioioi.contests.current_contest import contest_re
from oioioi.su import (
SU_BACKEND_SESSION_KEY,
SU_UID_SESSION_KEY,
SU_REAL_USER_IS_SUPERUSER,
SU_ORIGINAL_CONTEST,
BLOCKED_URL_NAMESPACES,
BLOCKED_URLS
)
from oioioi.su.utils import get_user

REDIRECTION_AFTER_SU_KEY = "redirection_after_su"
Expand All @@ -20,7 +30,9 @@ def __init__(self, get_response):
self.get_response = get_response

def __call__(self, request):
self._process_request(request)
response = self._process_request(request)
if response:
return response

return self.get_response(request)

Expand All @@ -39,6 +51,40 @@ def _process_request(self, request):
request.session[SU_UID_SESSION_KEY],
request.session[SU_BACKEND_SESSION_KEY],
)
# Check if the user is contest admin.
if not request.session.get(SU_REAL_USER_IS_SUPERUSER, True):
original_contest_id = request.session.get(SU_ORIGINAL_CONTEST)
contest_id = None
m = contest_re.match(request.path)
if m is not None:
contest_id = m.group('c_name')
url = resolve(request.path_info)
is_su_reset_url = url.url_name == 'su_reset'
nonoioioi_namespace = url.namespaces == []
for ns in url.namespaces:
if ns != 'contest' and ns != 'noncontest':
nonoioioi_namespace = True
break
MasloMaslane marked this conversation as resolved.
Show resolved Hide resolved
# Redirect if the url is not in the same contest, is not a su reset url and is an url made by oioioi.
# For example, `nonoioioi_namespace` can be True when the url is /jsi18n/
if (
not is_su_reset_url and
not nonoioioi_namespace and
(contest_id is None or contest_id != original_contest_id)
):
return redirect('su_url_not_allowed', contest_id=original_contest_id)

for ns in url.namespaces:
if ns in BLOCKED_URL_NAMESPACES:
return redirect('su_url_not_allowed', contest_id=original_contest_id)
if url.url_name in BLOCKED_URLS:
return redirect('su_url_not_allowed', contest_id=original_contest_id)

if (
not is_su_reset_url and
getattr(settings, 'ALLOW_ONLY_GET_FOR_SU_CONTEST_ADMINS', True) and request.method != 'GET'
):
return redirect('su_method_not_allowed', contest_id=original_contest_id)


class SuFirstTimeRedirectionMiddleware(object):
Expand Down
20 changes: 20 additions & 0 deletions oioioi/su/templates/su/method-not-allowed.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{% extends "simple-centered.html" %}

{% load i18n %}

{% block title %}{% trans "SU - Method not allowed" %}{% endblock %}

{% block content %}

<div class="text-center mt-3">
<h3>
{% blocktrans %}
Sorry, this OIOIOI instance only allows GET requests for contest admins using su.
{% endblocktrans %}
</h3>
<a href="{% url 'contest_dashboard' %}" class="btn btn-primary mt-3">
{% trans "Return to the contest" %}
</a>
</div>

{% endblock %}
2 changes: 1 addition & 1 deletion oioioi/su/templates/su/navbar-su-form.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{% load i18n simple_filters %}

{% if ctx.user.is_superuser %}
{% if ctx.user.is_superuser or is_valid_contest_admin %}
<div class="dropdown-divider mb-0" role="separator"></div>
<form class="p-3" action="{% url 'su' %}" method="post" id="su-dropdown-form">
{% csrf_token %}
Expand Down
20 changes: 20 additions & 0 deletions oioioi/su/templates/su/url-not-allowed.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{% extends "simple-centered.html" %}

{% load i18n %}

{% block title %}{% trans "SU - URL not allowed" %}{% endblock %}

{% block content %}

<div class="text-center mt-3">
<h3>
{% blocktrans %}
Sorry, you can't access this page.
{% endblocktrans %}
</h3>
<a href="{% url 'contest_dashboard' %}" class="btn btn-primary mt-3">
{% trans "Return to the contest" %}
</a>
</div>

{% endblock %}
13 changes: 12 additions & 1 deletion oioioi/su/templatetags/get_su.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,22 @@
@register.inclusion_tag('su/navbar-su-form.html', takes_context=True)
def su_dropdown_form(context):
from oioioi.su.forms import SuForm
from oioioi.su.utils import is_under_su
from oioioi.contests.utils import is_contest_basicadmin, contest_exists
from oioioi.su.utils import is_under_su, can_contest_admins_su, is_real_superuser

request = context['request']
# Checking if real user is a superuser blocks the ability to switch when switched to contest admin.
is_valid_contest_admin = (
can_contest_admins_su(request) and
contest_exists(request) and
is_contest_basicadmin(request) and
not is_real_superuser(request)
)

return {
'ctx': context,
'form': SuForm(auto_id='su-%s'),
'is_under_su': is_under_su(context['request']),
'num_hints': getattr(settings, 'NUM_HINTS', 10),
'is_valid_contest_admin': is_valid_contest_admin,
}
168 changes: 167 additions & 1 deletion oioioi/su/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@

from oioioi.base.tests import TestCase
from oioioi.contests.current_contest import ContestMode
from oioioi.su import SU_BACKEND_SESSION_KEY, SU_UID_SESSION_KEY
from oioioi.contests.models import Contest
from oioioi.participants.models import Participant
from oioioi.su import SU_BACKEND_SESSION_KEY, SU_UID_SESSION_KEY, SU_REAL_USER_IS_SUPERUSER, SU_ORIGINAL_CONTEST
from oioioi.su.utils import get_user, su_to_user


Expand Down Expand Up @@ -216,3 +218,167 @@ def test_su_status(self):
self.assertEqual(False, response['is_superuser'])
self.assertEqual('test_admin', response['real_user'])
self.assertEqual('test_user', response['user'])


@override_settings(CONTEST_ADMINS_CAN_SU=True)
class TestContestAdminsSu(TestCase):
fixtures = ['test_users', 'test_contest', 'test_permissions']

def _test_su_visibility(self, contest, expected):
self.assertTrue(self.client.login(username='test_contest_basicadmin'))
url = reverse('contest_dashboard', kwargs={'contest_id': contest.id})
response = self.client.get(url, follow=True)
self.assertEqual(200, response.status_code)
self.assertEqual(expected, 'Login as user' in response.content.decode())

def _add_user_to_contest(self, username):
contest = Contest.objects.get()
p = Participant()
p.user = User.objects.get(username=username)
p.contest = contest
p.save()

def _do_su(self, username, backend, expected_fail, fail_code=400):
contest = Contest.objects.get()
user = User.objects.get(username=username)
response = self.client.post(
reverse('su', kwargs={'contest_id': contest.id}),
{
'user': username,
'backend': backend,
}
)
if expected_fail:
self.assertEqual(fail_code, response.status_code)
else:
self.assertEqual(302, response.status_code)
session = self.client.session
self.assertEqual(user.id, session[SU_UID_SESSION_KEY])
self.assertEqual(backend, session[SU_BACKEND_SESSION_KEY])
self.assertFalse(session[SU_REAL_USER_IS_SUPERUSER])
self.assertEqual(contest.id, session[SU_ORIGINAL_CONTEST])

@override_settings(CONTEST_ADMINS_CAN_SU=False)
def test_su_unavailable(self):
contest = Contest.objects.get()
self._test_su_visibility(contest, False)

def test_su_available(self):
contest = Contest.objects.get()
self._test_su_visibility(contest, True)

def test_users_list(self):
# Tests if contest admin can only see hints with participants of the contest.
self.assertTrue(self.client.login(username='test_contest_basicadmin'))
contest = Contest.objects.get()
self._add_user_to_contest('test_user')

response = self.client.get(
reverse('get_suable_users', kwargs={'contest_id': contest.id}),
{'substr': 'te'}
)
response = response.json()
self.assertListEqual(
[
'test_user (Test User)',
],
response,
)

def test_su(self):
self.assertTrue(self.client.login(username='test_contest_basicadmin'))
contest = Contest.objects.get()
self._add_user_to_contest('test_user')

# Should fail because this user is not in the contest.
self._do_su('test_user2', 'django.contrib.auth.backends.ModelBackend', True)

# Should work because this user is in the contest.
self._do_su('test_user', 'django.contrib.auth.backends.ModelBackend', False)

# Su-ed contest admin shouldn't be able to go to urls outside the contest.
second_contest = Contest.objects.create(
id='c2',
controller_name='oioioi.programs.controllers.ProgrammingContestController',
name='Test contest',
)
urls = [
reverse('contest_dashboard', kwargs={'contest_id': second_contest.id}),
reverse('select_contest', kwargs={'contest_id': None}),
]
for url in urls:
response = self.client.get(url)
self.assertEqual(302, response.status_code)
self.assertEqual(
reverse('su_url_not_allowed', kwargs={'contest_id': contest.id}),
response.url
)

# Contest admin should be able to reset su.
response = self.client.post(reverse('su_reset'))
self.assertEqual(302, response.status_code)
session = self.client.session
self.assertNotIn(SU_UID_SESSION_KEY, session)
self.assertNotIn(SU_BACKEND_SESSION_KEY, session)
self.assertNotIn(SU_REAL_USER_IS_SUPERUSER, session)
self.assertNotIn(SU_ORIGINAL_CONTEST, session)

# The user is not a contest admin in the second contest.
self._test_su_visibility(second_contest, False)

def _test_post(self, can_post):
self.assertTrue(self.client.login(username='test_contest_basicadmin'))
self._add_user_to_contest('test_user')
self._do_su('test_user', 'django.contrib.auth.backends.ModelBackend', False)
contest = Contest.objects.get()
response = self.client.post(reverse('contest_dashboard', kwargs={'contest_id': contest.id}))
if can_post:
self.assertEqual(200, response.status_code)
else:
self.assertEqual(302, response.status_code)
self.assertEqual(
reverse('su_method_not_allowed', kwargs={'contest_id': contest.id}),
response.url
)

@override_settings(ALLOW_ONLY_GET_FOR_SU_CONTEST_ADMINS=True)
def test_cant_post(self):
self._test_post(False)

@override_settings(ALLOW_ONLY_GET_FOR_SU_CONTEST_ADMINS=False)
def test_can_post(self):
self._test_post(True)

def test_blocked_accounts(self):
# Tests if contest admins can't su to superusers and other contest admins.
self.assertTrue(self.client.login(username='test_contest_basicadmin'))
contest = Contest.objects.get()
self._add_user_to_contest('test_admin')
self._add_user_to_contest('test_contest_admin')

for username in ['test_admin', 'test_contest_admin']:
response = self.client.get(
reverse('get_suable_users', kwargs={'contest_id': contest.id}),
{'substr': username[:2]}
)
response = response.json()
self.assertNotIn(username, response)

self._do_su('test_contest_admin', 'django.contrib.auth.backends.ModelBackend', True)

# Shows the su form with an error message that switching to superuser is forbidden.
self._do_su('test_admin', 'django.contrib.auth.backends.ModelBackend', True, 200)
session = self.client.session
self.assertNotIn(SU_UID_SESSION_KEY, session)
self.assertNotIn(SU_BACKEND_SESSION_KEY, session)
self.assertNotIn(SU_REAL_USER_IS_SUPERUSER, session)
self.assertNotIn(SU_ORIGINAL_CONTEST, session)

def superusers_cant_su(self):
# Tests if superusers switched to contest admins can't switch to other contest admins.
self.assertTrue(self.client.login(username='test_admin'))
contest = Contest.objects.get()
self._add_user_to_contest('test_contest_admin')
self._add_user_to_contest('test_contest_basicadmin')
self._do_su('test_contest_admin', 'django.contrib.auth.backends.ModelBackend', False)
self._do_su('test_contest_basicadmin', 'django.contrib.auth.backends.ModelBackend', True)
7 changes: 6 additions & 1 deletion oioioi/su/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@

app_name = 'su'

noncontest_patterns = [
urlpatterns = [
re_path(r'^su/$', views.su_view, name='su'),
re_path(
r'^get_suable_usernames/', views.get_suable_users_view, name='get_suable_users'
),
re_path(r'^su_reset/$', views.su_reset_view, name='su_reset'),
]

contest_patterns = [
re_path(r'^su_method_not_allowed/', views.method_not_allowed_view, name='su_method_not_allowed'),
re_path(r'^su_url_not_allowed/', views.url_not_allowed_view, name='su_url_not_allowed'),
]
Loading
Loading