Commit 0f544778 authored by BAIRE Anthony's avatar BAIRE Anthony
Browse files

refactor the permission mixins

remove the IsProviderMixin and introduce 3 new mixins:
- UserAccessMixin     -> must be a registered user
- ProviderAccessMixin -> user must be a provider
- AllAccessMixin      -> may or may not be a registered user

All these 3 mixins will also ensure that the user email is validated.

The purpose of the AllAccessMixin is to force the validation of the
email when the user is registered, thus the validation will be
requested when landing on the webapp_detail page rather than when
submitting the first job (which would be discarded)
parent bd716ef8
from django.conf import settings
from django.contrib.auth.mixins import UserPassesTestMixin
from django.contrib.auth.mixins import UserPassesTestMixin, LoginRequiredMixin
from django.core.exceptions import PermissionDenied
from django.http import HttpResponse, JsonResponse
from django.shortcuts import redirect
from allauth.account.models import EmailAddress
from .models import Job
from .helpers import get_request_user
class IsProviderMixin(object):
"""Authorize a user to access specific views
# FIXME: should we validate API calls with this mixin too ? The answer is not
# obvious because it would not be too good to break the API when the ToS are
# updated (at least there should be a grace period)
# FIXME: right now we ensure that at least one address is validated. Should we
# force the validation of all addresses ?
class AllgoValidAccountMixin:
"""Common mixin for allgo accounts validation
An account is valid if at least one of its email address is verified.
In the case of the 'ProviderAccessMixin' the verified address must also
have its domain listed in ALLGO_ALLOWED_DEVELOPER_DOMAINS.
def dispatch(self, request, *args, **kwargs):
user = request.user
if not user.is_anonymous():
# registered users must have their email validated
if isinstance(self, ProviderAccessMixin):
# user visiting 'ProviderAccess' pages must have at least one
# of their provider addresses validated
email_addresses = user.provider_addresses
if not email_addresses:
# user does not have Provider access
# -> return 403
raise PermissionDenied
# user visiting 'UserAccess' pages must have any of their
# addresses validated
email_addresses = EmailAddress.objects.filter(user=user)
if not any(addr.verified for addr in email_addresses):
# user has no verified address
# TODO: do not send the emails immediately but display a page
# with a link to send the email
for addr in email_addresses:
return redirect("account_email_verification_sent")
return super().dispatch(request, *args, **kwargs)
class UserAccessMixin(LoginRequiredMixin, AllgoValidAccountMixin):
"""Mixin to be included in views usable by registered users only"""
class ProviderAccessMixin(LoginRequiredMixin, AllgoValidAccountMixin):
"""Mixin to be included in views that require provider-level access
(i.e. user allowed to create new web applications)
class AllAccessMixin(AllgoValidAccountMixin):
"""Mixin to be included in views usable by any user (registered or not)
Note: the purpose of using this mixin (rather that no mixin at all) is that
it ensures that the user registration is complete (email address
validated). Thus the user is invited to complete the registration before
landing to the webapp_detail page rather that when he submits his first job
(which would be discarded)
email_addr = request.user.provider_address
if email_addr is None:
# user has no valid email address in the allowed domains
raise PermissionDenied
elif not email_addr.verified:
# user has a valid address but it is still unverified
return redirect("account_email_verification_sent")
# user has a valid and verified address
return super().dispatch(request, *args, **kwargs)
class JobAuthMixin(UserPassesTestMixin):
class JobAuthMixin(AllgoValidAccountMixin, UserPassesTestMixin):
"""Check authorization to access a given job"""
def test_func(self):
......@@ -631,30 +631,32 @@ def save_user_profile(sender, instance, **kwargs):
def provider_address(user):
"""Get the first EmailAddress in the allowed developer domains
def provider_addresses(user):
"""Get a tuple of EmailAddress in the allowed developer domains
WARNING: the returned EmailAddress may not be verified
WARNING: the returned EmailAddress items may not be verified
This function finds the first allauth EmailAddress of the user that matches
a domain listed in settings.ALLOWED_DEVELOPER_DOMAINS
This function lists all allauth EmailAddress of the user that match any
domain listed in settings.ALLOWED_DEVELOPER_DOMAINS
If None is returned, then the user is not allowed to create a webapp.
If an empty tuple is returned, then the user is not allowed to create a
When the function returns an EmailAddress this does not automatically imply
that the user can create a webapp, because we still have to ensure that the
email is validated (however this is sufficient for displaying the "create
webapp"/"import webapp" buttons).
When the function returns a non-empty tuple this does not automatically
imply that the user can create a webapp, because we still have to ensure
that the email is validated (however this is sufficient for displaying the
"create webapp"/"import webapp" buttons).
for email_addr in EmailAddress.objects.filter(user=user):
def get_domain(email_addr):
_, domain ="@")
return domain
except ValueError:
# malformatted email
if domain in settings.ALLOWED_DEVELOPER_DOMAINS:
return email_addr
return None
return None
return tuple(addr for addr in EmailAddress.objects.filter(user=user)
if get_domain(addr) in settings.ALLOWED_DEVELOPER_DOMAINS)
def is_provider(user):
"""Return true if the user has at least one email address in the allowed
......@@ -663,10 +665,10 @@ def is_provider(user):
Warning: this function returns True even if the email address is not
return user.provider_address is not None
return bool(user.provider_addresses)
# Add the `is_provider` function as a `User` model method
auth.models.User.add_to_class('provider_address', provider_address)
# Add the `provider_addresses` and `is_provider` methods to the `User` model
auth.models.User.add_to_class('provider_addresses', provider_addresses)
auth.models.User.add_to_class('is_provider', is_provider)
# NOTE: because there is a circular dependency between and
......@@ -72,7 +72,7 @@ from .forms import (
# Local imports
import config
from .helpers import get_base_url, get_ssh_data, upload_data, notify_controller, lookup_job_file, get_request_user, query_webapps_for_user
from .mixins import IsProviderMixin, JobAuthMixin
from .mixins import UserAccessMixin, ProviderAccessMixin, AllAccessMixin, JobAuthMixin
from .models import (
......@@ -106,7 +106,7 @@ def error_handler(status, reason, default, request, exception=None):
return default(request, exception)
class IndexDetail(TemplateView):
class IndexDetail(AllAccessMixin, TemplateView):
"""Home view
Generate the home as a standard `TemplateView` by calling a specific
......@@ -152,7 +152,7 @@ class LegacyWebappDetail(SingleObjectMixin, RedirectView):
# -----------------------------------------------------------------------------
class WebappList(ListView):
class WebappList(AllAccessMixin, ListView):
""" Display a paginated list of available webapps.
The webapps are filtered from the most recent to the oldest and no private
......@@ -179,7 +179,7 @@ class WebappList(ListView):
def get_queryset(self):
return query_webapps_for_user(self.request.user).order_by('-created_at')
class UserWebappList(ListView):
class UserWebappList(AllAccessMixin, ListView):
"""List of user's webapp
Returns all the webapps owned by a specific user. Only the user can its
......@@ -205,7 +205,7 @@ class UserWebappList(ListView):
return queryset
class WebappUpdate(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
class WebappUpdate(UserAccessMixin, SuccessMessageMixin, UpdateView):
"""Form to update the webapp data
......@@ -256,7 +256,7 @@ class WebappUpdate(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
return super(WebappUpdate, self).form_invalid(form)
class WebappCreate(SuccessMessageMixin, LoginRequiredMixin, IsProviderMixin, CreateView):
class WebappCreate(ProviderAccessMixin, SuccessMessageMixin, CreateView):
"""Create a new webapp
......@@ -340,7 +340,7 @@ def get_rails_webapp_metadata(*, webapp_id=None, docker_name=None):
log.error("webapp import error: failed to get %s (%s)", url, e)
class WebappImport(SuccessMessageMixin, LoginRequiredMixin, IsProviderMixin, FormView):
class WebappImport(ProviderAccessMixin, SuccessMessageMixin, FormView):
"""Import a new webapp
This only creates the Webapp entry (along with the tags and webapp
......@@ -449,7 +449,7 @@ class WebappImport(SuccessMessageMixin, LoginRequiredMixin, IsProviderMixin, For
self.object = webapp
return super().form_valid(form)
class WebappVersionImport(LoginRequiredMixin, DetailView):
class WebappVersionImport(UserAccessMixin, DetailView):
"""Import version
This view is enabled only for webapps created with imported=True
......@@ -544,7 +544,7 @@ class WebappVersionImport(LoginRequiredMixin, DetailView):
return HttpResponseRedirect(request.path_info)
class WebappJson(LoginRequiredMixin, DetailView):
class WebappJson(UserAccessMixin, DetailView):
"""json variant of the application details
(used by the /aio/apps/<DOCKER_NAME>/events endpoint)
......@@ -563,7 +563,7 @@ class WebappJson(LoginRequiredMixin, DetailView):
"sandbox_state": webapp.get_sandbox_state_display(),
class WebappSandboxPanel(LoginRequiredMixin, TemplateView):
class WebappSandboxPanel(UserAccessMixin, TemplateView):
"""Create a new sandbox for a given application
......@@ -709,7 +709,7 @@ class WebappSandboxPanel(LoginRequiredMixin, TemplateView):
# -----------------------------------------------------------------------------
class TagList(ListView):
class TagList(AllAccessMixin, ListView):
"""List all available tag along with their number of occurences
......@@ -756,7 +756,7 @@ class TagList(ListView):
return tags
class TagWebappList(ListView):
class TagWebappList(AllAccessMixin, ListView):
"""List all available webapps for a given tag
......@@ -781,7 +781,7 @@ class TagWebappList(ListView):
# -----------------------------------------------------------------------------
class UserUpdate(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
class UserUpdate(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
"""Update the user profile
......@@ -840,7 +840,7 @@ class UserToken(LoginRequiredMixin, RedirectView):
return reverse('main:user_detail')
class UserSSHAdd(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
class UserSSHAdd(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
"""Save a SSH key to the database.
......@@ -879,7 +879,7 @@ class UserSSHDelete(LoginRequiredMixin, RedirectView):
return reverse('main:user_detail')
class UserPasswordUpdate(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
class UserPasswordUpdate(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
"""Update the user's password.
We reuse the Django password form system in order to keep something robust
......@@ -923,7 +923,7 @@ class UserPasswordUpdate(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
# -----------------------------------------------------------------------------
class JobList(LoginRequiredMixin, ListView):
class JobList(UserAccessMixin, ListView):
"""Display the list of jobs for a given identified user
......@@ -1013,7 +1013,7 @@ class JobDetail(JobAuthMixin, DetailView):
return super().render_to_response(context, **kwargs)
class JobCreate(SuccessMessageMixin, CreateView):
class JobCreate(AllAccessMixin, SuccessMessageMixin, CreateView):
""" Display the data related a specific web and create a job instance
into the database
......@@ -1265,7 +1265,7 @@ class JobFileDownloadAll(JobAuthMixin, View):
# -----------------------------------------------------------------------------
class RunnerList(LoginRequiredMixin, ListView):
class RunnerList(UserAccessMixin, ListView):
"""List all runners of a given user
......@@ -1303,7 +1303,7 @@ class RunnerList(LoginRequiredMixin, ListView):
return super().get_context_data(**kwargs)
class RunnerCreate(SuccessMessageMixin, LoginRequiredMixin, IsProviderMixin, CreateView):
class RunnerCreate(ProviderAccessMixin, SuccessMessageMixin, CreateView):
"""Create a runner and save it into the database
......@@ -1350,7 +1350,7 @@ class RunnerCreate(SuccessMessageMixin, LoginRequiredMixin, IsProviderMixin, Cre
return reverse_lazy('main:runner_update', args=(,))
class RunnerUpdate(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
class RunnerUpdate(UserAccessMixin, SuccessMessageMixin, UpdateView):
"""Update a runner and save it into the database
......@@ -1379,7 +1379,7 @@ class RunnerUpdate(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
"allgo/runner", "-", self.object.token, get_base_url(self.request)]
return super().get_context_data(**kwargs)
class RunnerDelete(LoginRequiredMixin, DeleteView):
class RunnerDelete(UserAccessMixin, DeleteView):
"""Delete a runner
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment