Merge pull request #1002 from ae-utbm/perms

Permissions refactor
This commit is contained in:
Bartuccio Antoine 2025-01-14 17:58:22 +01:00 committed by GitHub
commit 80940765fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 1003 additions and 455 deletions

View File

@ -7,7 +7,7 @@ from ninja_extra.schemas import PaginatedResponseSchema
from accounting.models import ClubAccount, Company
from accounting.schemas import ClubAccountSchema, CompanySchema
from core.api_permissions import CanAccessLookup
from core.auth.api_permissions import CanAccessLookup
@api_controller("/lookup", permissions=[CanAccessLookup])

View File

@ -17,6 +17,7 @@ import collections
from django import forms
from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction
from django.db.models import Sum
@ -44,15 +45,15 @@ from accounting.widgets.select import (
)
from club.models import Club
from club.widgets.select import AutoCompleteSelectClub
from core.models import User
from core.views import (
from core.auth.mixins import (
CanCreateMixin,
CanEditMixin,
CanEditPropMixin,
CanViewMixin,
TabedViewMixin,
)
from core.models import User
from core.views.forms import SelectDate, SelectFile
from core.views.mixins import TabedViewMixin
from core.views.widgets.select import AutoCompleteSelectUser
from counter.models import Counter, Product, Selling
@ -86,12 +87,13 @@ class SimplifiedAccountingTypeEditView(CanViewMixin, UpdateView):
template_name = "core/edit.jinja"
class SimplifiedAccountingTypeCreateView(CanCreateMixin, CreateView):
class SimplifiedAccountingTypeCreateView(PermissionRequiredMixin, CreateView):
"""Create an accounting type (for the admins)."""
model = SimplifiedAccountingType
fields = ["label", "accounting_type"]
template_name = "core/create.jinja"
permission_required = "accounting.add_simplifiedaccountingtype"
# Accounting types
@ -113,12 +115,13 @@ class AccountingTypeEditView(CanViewMixin, UpdateView):
template_name = "core/edit.jinja"
class AccountingTypeCreateView(CanCreateMixin, CreateView):
class AccountingTypeCreateView(PermissionRequiredMixin, CreateView):
"""Create an accounting type (for the admins)."""
model = AccountingType
fields = ["code", "label", "movement_type"]
template_name = "core/create.jinja"
permission_required = "accounting.add_accountingtype"
# BankAccount views

View File

@ -7,7 +7,7 @@ from ninja_extra.schemas import PaginatedResponseSchema
from club.models import Club
from club.schemas import ClubSchema
from core.api_permissions import CanAccessLookup
from core.auth.api_permissions import CanAccessLookup
@api_controller("/club")

View File

@ -25,6 +25,7 @@
import csv
from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
from django.core.paginator import InvalidPage, Paginator
from django.db.models import Sum
@ -49,17 +50,15 @@ from com.views import (
PosterEditBaseView,
PosterListBaseView,
)
from core.models import PageRev
from core.views import (
from core.auth.mixins import (
CanCreateMixin,
CanEditMixin,
CanEditPropMixin,
CanViewMixin,
DetailFormView,
PageEditViewBase,
TabedViewMixin,
UserIsRootMixin,
)
from core.models import PageRev
from core.views import DetailFormView, PageEditViewBase
from core.views.mixins import TabedViewMixin
from counter.models import Selling
@ -474,13 +473,14 @@ class ClubEditPropView(ClubTabsMixin, CanEditPropMixin, UpdateView):
current_tab = "props"
class ClubCreateView(CanCreateMixin, CreateView):
class ClubCreateView(PermissionRequiredMixin, CreateView):
"""Create a club (for the Sith admin)."""
model = Club
pk_url_kwarg = "club_id"
fields = ["name", "unix_name", "parent"]
template_name = "core/edit.jinja"
permission_required = "club.add_club"
class MembershipSetOldView(CanEditMixin, DetailView):
@ -512,12 +512,13 @@ class MembershipSetOldView(CanEditMixin, DetailView):
)
class MembershipDeleteView(UserIsRootMixin, DeleteView):
class MembershipDeleteView(PermissionRequiredMixin, DeleteView):
"""Delete a membership (for admins only)."""
model = Membership
pk_url_kwarg = "membership_id"
template_name = "core/delete_confirm.jinja"
permission_required = "club.delete_membership"
def get_success_url(self):
return reverse_lazy("core:user_clubs", kwargs={"user_id": self.object.user.id})

View File

@ -68,7 +68,10 @@ class NewsQuerySet(models.QuerySet):
"""
if user.has_perm("com.view_unmoderated_news"):
return self
return self.filter(Q(is_moderated=True) | Q(author_id=user.id))
q_filter = Q(is_moderated=True)
if user.is_authenticated:
q_filter |= Q(author_id=user.id)
return self.filter(q_filter)
class News(models.Model):
@ -149,8 +152,12 @@ class News(models.Model):
self.author_id == user.id or user.has_perm("com.change_news")
)
def can_be_viewed_by(self, user):
return self.is_moderated or user.has_perm("com.view_unmoderated_news")
def can_be_viewed_by(self, user: User):
return (
self.is_moderated
or user.has_perm("com.view_unmoderated_news")
or (user.is_authenticated and self.author_id == user.id)
)
def news_notification_callback(notif):

View File

@ -159,13 +159,13 @@ class TestNews(TestCase):
def test_news_viewer(self):
"""Test that moderated news can be viewed by anyone
and not moderated news only by com admins.
and not moderated news only by com admins and by their author.
"""
# by default a news isn't moderated
# by default news aren't moderated
assert self.new.can_be_viewed_by(self.com_admin)
assert self.new.can_be_viewed_by(self.author)
assert not self.new.can_be_viewed_by(self.sli)
assert not self.new.can_be_viewed_by(self.anonymous)
assert not self.new.can_be_viewed_by(self.author)
self.new.is_moderated = True
self.new.save()

View File

@ -27,10 +27,7 @@ from smtplib import SMTPRecipientsRefused
from typing import Any
from django.conf import settings
from django.contrib.auth.mixins import (
AccessMixin,
PermissionRequiredMixin,
)
from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin
from django.core.exceptions import PermissionDenied, ValidationError
from django.db.models import Max
from django.forms.models import modelform_factory
@ -47,13 +44,13 @@ from club.models import Club, Mailing
from com.calendar import IcsCalendar
from com.forms import NewsDateForm, NewsForm, PosterForm
from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle
from core.models import User
from core.views import (
from core.auth.mixins import (
CanEditPropMixin,
CanViewMixin,
QuickNotifMixin,
TabedViewMixin,
PermissionOrAuthorRequiredMixin,
)
from core.models import User
from core.views.mixins import QuickNotifMixin, TabedViewMixin
from core.views.widgets.markdown import MarkdownInput
# Sith object
@ -169,24 +166,17 @@ class NewsCreateView(PermissionRequiredMixin, CreateView):
return init
class NewsUpdateView(UpdateView):
class NewsUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
model = News
form_class = NewsForm
template_name = "com/news_edit.jinja"
pk_url_kwarg = "news_id"
def dispatch(self, request, *args, **kwargs):
if (
not request.user.has_perm("com.edit_news")
and self.get_object().author != request.user
):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
permission_required = "com.edit_news"
def form_valid(self, form):
self.object = form.save()
response = super().form_valid(form) # Does the saving part
IcsCalendar.make_internal()
return super().form_valid(form)
return response
def get_date_form_kwargs(self) -> dict[str, Any]:
"""Get initial data for NewsDateForm"""
@ -209,7 +199,7 @@ class NewsUpdateView(UpdateView):
}
class NewsDeleteView(PermissionRequiredMixin, DeleteView):
class NewsDeleteView(PermissionOrAuthorRequiredMixin, DeleteView):
model = News
pk_url_kwarg = "news_id"
template_name = "core/delete_confirm.jinja"

View File

@ -11,10 +11,7 @@ from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from club.models import Mailing
from core.api_permissions import (
CanAccessLookup,
CanView,
)
from core.auth.api_permissions import CanAccessLookup, CanView
from core.models import Group, SithFile, User
from core.schemas import (
FamilyGodfatherSchema,

0
core/auth/__init__.py Normal file
View File

View File

@ -3,7 +3,8 @@
Some permissions are global (like `IsInGroup` or `IsRoot`),
and some others are per-object (like `CanView` or `CanEdit`).
Examples:
Example:
```python
# restrict all the routes of this controller
# to subscribed users
@api_controller("/foo", permissions=[IsSubscriber])
@ -33,6 +34,7 @@ Examples:
]
def bar_delete(self, bar_id: int):
# ...
```
"""
from typing import Any

287
core/auth/mixins.py Normal file
View File

@ -0,0 +1,287 @@
#
# Copyright 2016,2017
# - Skia <skia@libskia.so>
# - Sli <antoine@bartuccio.fr>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from __future__ import annotations
import types
import warnings
from typing import TYPE_CHECKING, Any, LiteralString
from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin
from django.core.exceptions import ImproperlyConfigured, PermissionDenied
from django.views.generic.base import View
if TYPE_CHECKING:
from django.db.models import Model
from core.models import User
def can_edit_prop(obj: Any, user: User) -> bool:
"""Can the user edit the properties of the object.
Args:
obj: Object to test for permission
user: core.models.User to test permissions against
Returns:
True if user is authorized to edit object properties else False
Example:
```python
if not can_edit_prop(self.object ,request.user):
raise PermissionDenied
```
"""
return obj is None or user.is_owner(obj)
def can_edit(obj: Any, user: User) -> bool:
"""Can the user edit the object.
Args:
obj: Object to test for permission
user: core.models.User to test permissions against
Returns:
True if user is authorized to edit object else False
Example:
```python
if not can_edit(self.object, request.user):
raise PermissionDenied
```
"""
if obj is None or user.can_edit(obj):
return True
return can_edit_prop(obj, user)
def can_view(obj: Any, user: User) -> bool:
"""Can the user see the object.
Args:
obj: Object to test for permission
user: core.models.User to test permissions against
Returns:
True if user is authorized to see object else False
Example:
```python
if not can_view(self.object ,request.user):
raise PermissionDenied
```
"""
if obj is None or user.can_view(obj):
return True
return can_edit(obj, user)
class GenericContentPermissionMixinBuilder(View):
"""Used to build permission mixins.
This view protect any child view that would be showing an object that is restricted based
on two properties.
Attributes:
raised_error: permission to be raised
"""
raised_error = PermissionDenied
@staticmethod
def permission_function(obj: Any, user: User) -> bool:
"""Function to test permission with."""
return False
@classmethod
def get_permission_function(cls, obj, user):
return cls.permission_function(obj, user)
def dispatch(self, request, *arg, **kwargs):
if hasattr(self, "get_object") and callable(self.get_object):
self.object = self.get_object()
if not self.get_permission_function(self.object, request.user):
raise self.raised_error
return super().dispatch(request, *arg, **kwargs)
# If we get here, it's a ListView
queryset = self.get_queryset()
l_id = [o.id for o in queryset if self.get_permission_function(o, request.user)]
if not l_id and queryset.count() != 0:
raise self.raised_error
self._get_queryset = self.get_queryset
def get_qs(self2):
return self2._get_queryset().filter(id__in=l_id)
self.get_queryset = types.MethodType(get_qs, self)
return super().dispatch(request, *arg, **kwargs)
class CanCreateMixin(View):
"""Protect any child view that would create an object.
Raises:
PermissionDenied:
If the user has not the necessary permission
to create the object of the view.
"""
def __init_subclass__(cls, **kwargs):
warnings.warn(
f"{cls.__name__} is deprecated and should be replaced "
"by other permission verification mecanism.",
DeprecationWarning,
stacklevel=2,
)
super().__init_subclass__(**kwargs)
def __init__(self, *args, **kwargs):
warnings.warn(
f"{self.__class__.__name__} is deprecated and should be replaced "
"by other permission verification mecanism.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(*args, **kwargs)
def dispatch(self, request, *arg, **kwargs):
res = super().dispatch(request, *arg, **kwargs)
if not request.user.is_authenticated:
raise PermissionDenied
return res
def form_valid(self, form):
obj = form.instance
if can_edit_prop(obj, self.request.user):
return super().form_valid(form)
raise PermissionDenied
class CanEditPropMixin(GenericContentPermissionMixinBuilder):
"""Ensure the user has owner permissions on the child view object.
In other word, you can make a view with this view as parent,
and it will be retricted to the users that are in the
object's owner_group or that pass the `obj.can_be_viewed_by` test.
Raises:
PermissionDenied: If the user cannot see the object
"""
permission_function = can_edit_prop
class CanEditMixin(GenericContentPermissionMixinBuilder):
"""Ensure the user has permission to edit this view's object.
Raises:
PermissionDenied: if the user cannot edit this view's object.
"""
permission_function = can_edit
class CanViewMixin(GenericContentPermissionMixinBuilder):
"""Ensure the user has permission to view this view's object.
Raises:
PermissionDenied: if the user cannot edit this view's object.
"""
permission_function = can_view
class FormerSubscriberMixin(AccessMixin):
"""Check if the user was at least an old subscriber.
Raises:
PermissionDenied: if the user never subscribed.
"""
def dispatch(self, request, *args, **kwargs):
if not request.user.was_subscribed:
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
class PermissionOrAuthorRequiredMixin(PermissionRequiredMixin):
"""Require that the user has the required perm or is the object author.
This mixin can be used in combination with `DetailView`,
or another base class that implements the `get_object` method.
Example:
In the following code, a user will be able
to edit news if he has the `com.change_news` permission
or if he tries to edit his own news :
```python
class NewsEditView(PermissionOrAuthorRequiredMixin, DetailView):
model = News
author_field = "author"
permission_required = "com.change_news"
```
This is more or less equivalent to :
```python
class NewsEditView(PermissionOrAuthorRequiredMixin, DetailView):
model = News
def dispatch(self, request, *args, **kwargs):
self.object = self.get_object()
if not (
user.has_perm("com.change_news")
or self.object.author == request.user
):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
```
"""
author_field: LiteralString = "author"
def has_permission(self):
if not hasattr(self, "get_object"):
raise ImproperlyConfigured(
f"{self.__class__.__name__} is missing the "
"get_object attribute. "
f"Define {self.__class__.__name__}.get_object, "
"or inherit from a class that implement it (like DetailView)"
)
if super().has_permission():
return True
if self.request.user.is_anonymous:
return False
obj: Model = self.get_object()
if not self.author_field.endswith("_id"):
# getting the related model could trigger a db query
# so we will rather get the foreign value than
# the object itself.
self.author_field += "_id"
author_id = getattr(obj, self.author_field, None)
return author_id == self.request.user.id

View File

@ -894,7 +894,9 @@ Welcome to the wiki page!
public_group = Group.objects.create(name="Public")
subscribers = Group.objects.create(name="Subscribers")
subscribers.permissions.add(*list(perms.filter(codename__in=["add_news"])))
subscribers.permissions.add(
*list(perms.filter(codename__in=["add_news", "add_uvcommentreport"]))
)
old_subscribers = Group.objects.create(name="Old subscribers")
old_subscribers.permissions.add(
*list(

View File

@ -30,7 +30,7 @@
{% if m.can_be_edited_by(user) %}
<td><a href="{{ url('club:membership_set_old', membership_id=m.id) }}">{% trans %}Mark as old{% endtrans %}</a></td>
{% endif %}
{% if user.is_root %}
{% if user.has_perm("club.delete_membership") %}
<td><a href="{{ url('club:membership_delete', membership_id=m.id) }}">{% trans %}Delete{% endtrans %}</a></td>
{% endif %}
</tr>
@ -59,7 +59,7 @@
<td>{{ m.description }}</td>
<td>{{ m.start_date }}</td>
<td>{{ m.end_date }}</td>
{% if user.is_root %}
{% if user.has_perm("club.delete_membership") %}
<td><a href="{{ url('club:membership_delete', membership_id=m.id) }}">{% trans %}Delete{% endtrans %}</a></td>
{% endif %}
</tr>

View File

@ -327,12 +327,9 @@ http://git.an
class TestUserTools:
def test_anonymous_user_unauthorized(self, client):
"""An anonymous user shouldn't have access to the tools page."""
response = client.get(reverse("core:user_tools"))
assertRedirects(
response,
expected_url="/login?next=%2Fuser%2Ftools%2F",
target_status_code=301,
)
url = reverse("core:user_tools")
response = client.get(url)
assertRedirects(response, expected_url=reverse("core:login") + f"?next={url}")
@pytest.mark.parametrize("username", ["guy", "root", "skia", "comunity"])
def test_page_is_working(self, client, username):

View File

@ -22,15 +22,6 @@
#
#
import types
from typing import Any
from django.conf import settings
from django.contrib.auth.mixins import AccessMixin
from django.core.exceptions import (
ImproperlyConfigured,
PermissionDenied,
)
from django.http import (
HttpResponseForbidden,
HttpResponseNotFound,
@ -38,12 +29,10 @@ from django.http import (
)
from django.shortcuts import render
from django.utils.functional import cached_property
from django.views.generic.base import View
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import FormView
from sentry_sdk import last_event_id
from core.models import User
from core.views.forms import LoginForm
@ -65,254 +54,6 @@ def internal_servor_error(request):
return HttpResponseServerError(render(request, "core/500.jinja"))
def can_edit_prop(obj: Any, user: User) -> bool:
"""Can the user edit the properties of the object.
Args:
obj: Object to test for permission
user: core.models.User to test permissions against
Returns:
True if user is authorized to edit object properties else False
Examples:
```python
if not can_edit_prop(self.object ,request.user):
raise PermissionDenied
```
"""
return obj is None or user.is_owner(obj)
def can_edit(obj: Any, user: User) -> bool:
"""Can the user edit the object.
Args:
obj: Object to test for permission
user: core.models.User to test permissions against
Returns:
True if user is authorized to edit object else False
Examples:
```python
if not can_edit(self.object, request.user):
raise PermissionDenied
```
"""
if obj is None or user.can_edit(obj):
return True
return can_edit_prop(obj, user)
def can_view(obj: Any, user: User) -> bool:
"""Can the user see the object.
Args:
obj: Object to test for permission
user: core.models.User to test permissions against
Returns:
True if user is authorized to see object else False
Examples:
```python
if not can_view(self.object ,request.user):
raise PermissionDenied
```
"""
if obj is None or user.can_view(obj):
return True
return can_edit(obj, user)
class GenericContentPermissionMixinBuilder(View):
"""Used to build permission mixins.
This view protect any child view that would be showing an object that is restricted based
on two properties.
Attributes:
raised_error: permission to be raised
"""
raised_error = PermissionDenied
@staticmethod
def permission_function(obj: Any, user: User) -> bool:
"""Function to test permission with."""
return False
@classmethod
def get_permission_function(cls, obj, user):
return cls.permission_function(obj, user)
def dispatch(self, request, *arg, **kwargs):
if hasattr(self, "get_object") and callable(self.get_object):
self.object = self.get_object()
if not self.get_permission_function(self.object, request.user):
raise self.raised_error
return super().dispatch(request, *arg, **kwargs)
# If we get here, it's a ListView
queryset = self.get_queryset()
l_id = [o.id for o in queryset if self.get_permission_function(o, request.user)]
if not l_id and queryset.count() != 0:
raise self.raised_error
self._get_queryset = self.get_queryset
def get_qs(self2):
return self2._get_queryset().filter(id__in=l_id)
self.get_queryset = types.MethodType(get_qs, self)
return super().dispatch(request, *arg, **kwargs)
class CanCreateMixin(View):
"""Protect any child view that would create an object.
Raises:
PermissionDenied:
If the user has not the necessary permission
to create the object of the view.
"""
def dispatch(self, request, *arg, **kwargs):
res = super().dispatch(request, *arg, **kwargs)
if not request.user.is_authenticated:
raise PermissionDenied
return res
def form_valid(self, form):
obj = form.instance
if can_edit_prop(obj, self.request.user):
return super().form_valid(form)
raise PermissionDenied
class CanEditPropMixin(GenericContentPermissionMixinBuilder):
"""Ensure the user has owner permissions on the child view object.
In other word, you can make a view with this view as parent,
and it will be retricted to the users that are in the
object's owner_group or that pass the `obj.can_be_viewed_by` test.
Raises:
PermissionDenied: If the user cannot see the object
"""
permission_function = can_edit_prop
class CanEditMixin(GenericContentPermissionMixinBuilder):
"""Ensure the user has permission to edit this view's object.
Raises:
PermissionDenied: if the user cannot edit this view's object.
"""
permission_function = can_edit
class CanViewMixin(GenericContentPermissionMixinBuilder):
"""Ensure the user has permission to view this view's object.
Raises:
PermissionDenied: if the user cannot edit this view's object.
"""
permission_function = can_view
class UserIsRootMixin(GenericContentPermissionMixinBuilder):
"""Allow only root admins.
Raises:
PermissionDenied: if the user isn't root
"""
@staticmethod
def permission_function(obj: Any, user: User):
return user.is_root
class FormerSubscriberMixin(AccessMixin):
"""Check if the user was at least an old subscriber.
Raises:
PermissionDenied: if the user never subscribed.
"""
def dispatch(self, request, *args, **kwargs):
if not request.user.was_subscribed:
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
class SubscriberMixin(AccessMixin):
def dispatch(self, request, *args, **kwargs):
if not request.user.is_subscribed:
return self.handle_no_permission()
return super().dispatch(request, *args, **kwargs)
class TabedViewMixin(View):
"""Basic functions for displaying tabs in the template."""
def get_tabs_title(self):
if hasattr(self, "tabs_title"):
return self.tabs_title
raise ImproperlyConfigured("tabs_title is required")
def get_current_tab(self):
if hasattr(self, "current_tab"):
return self.current_tab
raise ImproperlyConfigured("current_tab is required")
def get_list_of_tabs(self):
if hasattr(self, "list_of_tabs"):
return self.list_of_tabs
raise ImproperlyConfigured("list_of_tabs is required")
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["tabs_title"] = self.get_tabs_title()
kwargs["current_tab"] = self.get_current_tab()
kwargs["list_of_tabs"] = self.get_list_of_tabs()
return kwargs
class QuickNotifMixin:
quick_notif_list = []
def dispatch(self, request, *arg, **kwargs):
# In some cases, the class can stay instanciated, so we need to reset the list
self.quick_notif_list = []
return super().dispatch(request, *arg, **kwargs)
def get_success_url(self):
ret = super().get_success_url()
if hasattr(self, "quick_notif_url_arg"):
if "?" in ret:
ret += "&" + self.quick_notif_url_arg
else:
ret += "?" + self.quick_notif_url_arg
return ret
def get_context_data(self, **kwargs):
"""Add quick notifications to context."""
kwargs = super().get_context_data(**kwargs)
kwargs["quick_notifs"] = []
for n in self.quick_notif_list:
kwargs["quick_notifs"].append(settings.SITH_QUICK_NOTIF[n])
for key, val in settings.SITH_QUICK_NOTIF.items():
for gk in self.request.GET:
if key == gk:
kwargs["quick_notifs"].append(val)
return kwargs
class DetailFormView(SingleObjectMixin, FormView):
"""Class that allow both a detail view and a form view."""
@ -326,14 +67,6 @@ class DetailFormView(SingleObjectMixin, FormView):
return super().get_object()
class AllowFragment:
"""Add `is_fragment` to templates. It's only True if the request is emitted by htmx"""
def get_context_data(self, **kwargs):
kwargs["is_fragment"] = self.request.headers.get("HX-Request", False)
return super().get_context_data(**kwargs)
# F403: those star-imports would be hellish to refactor
# E402: putting those import at the top of the file would also be difficult
from .files import * # noqa: F403 E402

View File

@ -33,14 +33,14 @@ from django.views.generic import DetailView, ListView
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import DeleteView, FormMixin, UpdateView
from core.models import Notification, SithFile, User
from core.views import (
AllowFragment,
from core.auth.mixins import (
CanEditMixin,
CanEditPropMixin,
CanViewMixin,
can_view,
)
from core.models import Notification, SithFile, User
from core.views.mixins import AllowFragment
from core.views.widgets.select import (
AutoCompleteSelectMultipleGroup,
AutoCompleteSelectSithFile,

View File

@ -16,13 +16,15 @@
"""Views to manage Groups."""
from django import forms
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic import ListView
from django.views.generic.edit import CreateView, DeleteView, UpdateView
from core.auth.mixins import CanEditMixin
from core.models import Group, User
from core.views import CanCreateMixin, CanEditMixin, DetailFormView
from core.views import DetailFormView
from core.views.widgets.select import AutoCompleteSelectMultipleUser
# Forms
@ -73,13 +75,14 @@ class GroupEditView(CanEditMixin, UpdateView):
fields = ["name", "description"]
class GroupCreateView(CanCreateMixin, CreateView):
class GroupCreateView(PermissionRequiredMixin, CreateView):
"""Add a new Group."""
model = Group
queryset = Group.objects.filter(is_manually_manageable=True)
template_name = "core/create.jinja"
fields = ["name", "description"]
permission_required = "core.add_group"
class GroupTemplateView(CanEditMixin, DetailFormView):

67
core/views/mixins.py Normal file
View File

@ -0,0 +1,67 @@
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.views import View
class TabedViewMixin(View):
"""Basic functions for displaying tabs in the template."""
def get_tabs_title(self):
if hasattr(self, "tabs_title"):
return self.tabs_title
raise ImproperlyConfigured("tabs_title is required")
def get_current_tab(self):
if hasattr(self, "current_tab"):
return self.current_tab
raise ImproperlyConfigured("current_tab is required")
def get_list_of_tabs(self):
if hasattr(self, "list_of_tabs"):
return self.list_of_tabs
raise ImproperlyConfigured("list_of_tabs is required")
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["tabs_title"] = self.get_tabs_title()
kwargs["current_tab"] = self.get_current_tab()
kwargs["list_of_tabs"] = self.get_list_of_tabs()
return kwargs
class QuickNotifMixin:
quick_notif_list = []
def dispatch(self, request, *arg, **kwargs):
# In some cases, the class can stay instanciated, so we need to reset the list
self.quick_notif_list = []
return super().dispatch(request, *arg, **kwargs)
def get_success_url(self):
ret = super().get_success_url()
if hasattr(self, "quick_notif_url_arg"):
if "?" in ret:
ret += "&" + self.quick_notif_url_arg
else:
ret += "?" + self.quick_notif_url_arg
return ret
def get_context_data(self, **kwargs):
"""Add quick notifications to context."""
kwargs = super().get_context_data(**kwargs)
kwargs["quick_notifs"] = []
for n in self.quick_notif_list:
kwargs["quick_notifs"].append(settings.SITH_QUICK_NOTIF[n])
for key, val in settings.SITH_QUICK_NOTIF.items():
for gk in self.request.GET:
if key == gk:
kwargs["quick_notifs"].append(val)
return kwargs
class AllowFragment:
"""Add `is_fragment` to templates. It's only True if the request is emitted by htmx"""
def get_context_data(self, **kwargs):
kwargs["is_fragment"] = self.request.headers.get("HX-Request", False)
return super().get_context_data(**kwargs)

View File

@ -21,8 +21,13 @@ from django.urls import reverse_lazy
from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, DeleteView, UpdateView
from core.auth.mixins import (
CanCreateMixin,
CanEditMixin,
CanEditPropMixin,
CanViewMixin,
)
from core.models import LockError, Page, PageRev
from core.views import CanCreateMixin, CanEditMixin, CanEditPropMixin, CanViewMixin
from core.views.forms import PageForm, PagePropForm
from core.views.widgets.markdown import MarkdownInput

View File

@ -54,14 +54,8 @@ from django.views.generic.dates import MonthMixin, YearMixin
from django.views.generic.edit import FormView, UpdateView
from honeypot.decorators import check_honeypot
from core.auth.mixins import CanEditMixin, CanEditPropMixin, CanViewMixin
from core.models import Gift, Preferences, User
from core.views import (
CanEditMixin,
CanEditPropMixin,
CanViewMixin,
QuickNotifMixin,
TabedViewMixin,
)
from core.views.forms import (
GiftForm,
LoginForm,
@ -70,6 +64,7 @@ from core.views.forms import (
UserGroupsForm,
UserProfileForm,
)
from core.views.mixins import QuickNotifMixin, TabedViewMixin
from counter.models import Refilling, Selling
from counter.views.student_card import StudentCardFormView
from eboutic.models import Invoice

View File

@ -20,7 +20,7 @@ from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from core.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
from core.auth.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
from counter.models import Counter, Product, ProductType
from counter.schemas import (
CounterFilterSchema,

View File

@ -24,8 +24,8 @@ from django.utils import timezone
from django.views.generic import DetailView, ListView, TemplateView
from django.views.generic.edit import CreateView, DeleteView, UpdateView
from core.auth.mixins import CanEditMixin, CanViewMixin
from core.utils import get_semester_code, get_start_of_semester
from core.views import CanEditMixin, CanViewMixin
from counter.forms import CounterEditForm, ProductEditForm
from counter.models import Counter, Product, ProductType, Refilling, Selling
from counter.utils import is_logged_in_counter

View File

@ -23,7 +23,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView
from django.views.generic.edit import UpdateView
from core.views import CanViewMixin
from core.auth.mixins import CanViewMixin
from counter.forms import CashSummaryFormBase
from counter.models import (
CashRegisterSummary,

View File

@ -31,9 +31,9 @@ from django.views.generic import FormView
from django.views.generic.detail import SingleObjectMixin
from ninja.main import HttpRequest
from core.auth.mixins import CanViewMixin
from core.models import User
from core.utils import FormFragmentTemplateData
from core.views import CanViewMixin
from counter.forms import RefillForm
from counter.models import Counter, Customer, Product, Selling
from counter.utils import is_logged_in_counter

View File

@ -18,7 +18,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, UpdateView
from core.views import CanViewMixin
from core.auth.mixins import CanViewMixin
from counter.forms import EticketForm
from counter.models import Eticket, Selling
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin

View File

@ -22,7 +22,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView
from django.views.generic.edit import FormMixin, ProcessFormView
from core.views import CanViewMixin
from core.auth.mixins import CanViewMixin
from core.views.forms import LoginForm
from counter.forms import GetUserForm
from counter.models import Counter

View File

@ -19,7 +19,7 @@ from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic.base import View
from core.views import TabedViewMixin
from core.views.mixins import TabedViewMixin
class CounterAdminMixin(View):

View File

@ -21,8 +21,8 @@ from django.urls import reverse
from django.utils.translation import gettext as _
from django.views.generic.edit import DeleteView, FormView
from core.auth.mixins import can_edit
from core.utils import FormFragmentTemplateData
from core.views import can_edit
from counter.forms import StudentCardForm
from counter.models import Customer, StudentCard
from counter.utils import is_logged_in_counter

View File

@ -1 +0,0 @@
::: core.api_permissions

View File

@ -0,0 +1,32 @@
## Backend
::: core.auth.backends
handler: python
options:
heading_level: 3
members:
- SithModelBackend
## Mixins
::: core.auth.mixins
handler: python
options:
heading_level: 3
members:
- can_edit_prop
- can_edit
- can_view
- CanCreateMixin
- CanEditMixin
- CanViewMixin
- FormerSubscriberMixin
- PermissionOrAuthorRequiredMixin
## API Permissions
::: core.auth.api_permissions
handler: python
options:
heading_level: 3

View File

@ -157,7 +157,9 @@ il est automatiquement ajouté au groupe des membres
du club.
Lorsqu'il quitte le club, il est retiré du groupe.
## Les principaux groupes utilisés
## Les groupes utilisés
### Groupes principaux
Les groupes les plus notables gérables par les administrateurs du site sont :
@ -168,15 +170,61 @@ Les groupes les plus notables gérables par les administrateurs du site sont :
- `SAS admin` : les administrateurs du SAS
- `Forum admin` : les administrateurs du forum
- `Pedagogy admin` : les administrateurs de la pédagogie (guide des UVs)
- `Banned from buying alcohol` : les utilisateurs interdits de vente d'alcool (non mineurs)
- `Banned from counters` : les utilisateurs interdits d'utilisation des comptoirs
- `Banned to subscribe` : les utilisateurs interdits de cotisation
En plus de ces groupes, on peut noter :
- `Public` : tous les utilisateurs du site
- `Subscribers` : tous les cotisants du site
- `Old subscribers` : tous les anciens cotisants
- `Public` : tous les utilisateurs du site.
Un utilisateur est automatiquement ajouté à ce group
lors de la création de son compte.
- `Subscribers` : tous les cotisants du site.
Les utilisateurs ne sont pas réellement ajoutés ce groupe ;
cependant, les utilisateurs cotisants sont implicitement
considérés comme membres du groupe lors de l'appel
à la méthode `User.has_perm`.
- `Old subscribers` : tous les anciens cotisants.
Un utilisateur est automatiquement ajouté à ce groupe
lors de sa première cotisation
!!!note "Utilisation du groupe Public"
Le groupe Public est un groupe particulier.
Tout le monde faisant partie de ce groupe
(même les utilisateurs non-connectés en sont implicitement
considérés comme membres),
il ne doit pas être utilisé pour résoudre les
permissions d'une vue.
En revanche, il est utile pour attribuer une ressource
à tout le monde.
Par exemple, un produit avec le groupe de vente Public
est considéré comme achetable par tous utilisateurs.
S'il n'avait eu aucun group de vente, il n'aurait
été accessible à personne.
### Groupes de club
Chaque club est associé à deux groupes :
le groupe des membres et le groupe du bureau.
Lorsqu'un utilisateur rejoint un club, il est automatiquement
ajouté au groupe des membres.
S'il rejoint le club en tant que membre du bureau,
il est également ajouté au groupe du bureau.
Lorsqu'un utilisateur quitte le club, il est automatiquement
retiré des groupes liés au club.
S'il quitte le bureau, mais reste dans le club,
il est retiré du groupe du bureau, mais reste dans le groupe des membres.
### Groupes de ban
Les groupes de ban sont une catégorie de groupes à part,
qui ne sont pas stockés dans la même table
et qui ne sont pas gérés sur la même interface
que les autres groupes.
Les groupes de ban existants sont les suivants :
- `Banned from buying alcohol` : les utilisateurs interdits de vente d'alcool (non mineurs)
- `Banned from counters` : les utilisateurs interdits d'utilisation des comptoirs
- `Banned to subscribe` : les utilisateurs interdits de cotisation

View File

@ -1,15 +1,292 @@
## Les permissions
## Objectifs du système de permissions
Le fonctionnement de l'AE ne permet pas d'utiliser le système de permissions
intégré à Django tel quel. Lors de la conception du Sith, ce qui paraissait le
plus simple à l'époque était de concevoir un système maison afin de se calquer
sur ce que faisait l'ancien site.
Les permissions attendues sur le site sont relativement spécifiques.
L'accès à une ressource peut se faire selon un certain nombre
de paramètres différents :
### Protéger un modèle
`L'état de la ressource`
: Certaines ressources
sont visibles par tous les cotisants (voire tous les utilisateurs),
à condition qu'elles aient passé une étape de modération.
La visibilité des ressources non-modérées nécessite des permissions
supplémentaires.
La gestion des permissions se fait directement par modèle.
Il existe trois niveaux de permission :
`L'appartenance à un groupe`
: Les groupes Root, Admin Com, Admin SAS, etc.
sont associés à des jeux de permissions.
Par exemple, les membres du groupe Admin SAS ont tous les droits sur
les ressources liées au SAS : ils peuvent voir,
créer, éditer, supprimer et éventuellement modérer
des images, des albums, des identifications de personnes...
Il en va de même avec les admins Com pour la communication,
les admins pédagogie pour le guide des UEs et ainsi de suite.
Quant aux membres du groupe Root, ils ont tous les droits
sur toutes les ressources du site.
`Le statut de la cotisation`
: Les non-cotisants n'ont presque aucun
droit sur les ressources du site (ils peuvent seulement en voir une poignée),
les anciens cotisants peuvent voir un grand nombre de ressources
et les cotisants actuels ont la plupart des droits qui ne sont
pas liés à un club ou à l'administration du site.
`L'appartenance à un club`
: Être dans un club donne le droit
de voir la plupart des ressources liées au club dans lequel ils
sont ; être dans le bureau du club donne en outre des droits
d'édition et de création sur ces ressources.
`Être l'auteur ou le possesseur d'une ressource`
: Certaines ressources, comme les nouvelles,
enregistrent l'utilisateur qui les a créées ;
ce dernier a les droits de voir, de modifier et éventuellement
de supprimer ses ressources, quand bien même
elles ne seraient pas visibles pour les utilisateurs normaux
(par exemple, parce qu'elles ne sont pas encore modérées.)
Le système de permissions inclus par défaut dans django
permet de modéliser aisément l'accès à des ressources au niveau
de la table.
Ainsi, il n'est pas compliqué de gérer les permissions liées
aux groupes d'administration.
Cependant, une surcouche est nécessaire dès lors que l'on veut
gérer les droits liés à une ligne en particulier
d'une table de la base de données.
Nous essayons le plus possible de nous tenir aux fonctionnalités
de django, sans pour autant hésiter à nous rabattre sur notre
propre surcouche dès lors que les permissions attendues
deviennent trop spécifiques pour être gérées avec juste django.
!!!info "Un peu d'histoire"
Les permissions du site n'ont pas toujours été gérées
avec un mélange de fonctionnalités de django et de notre
propre code.
Pendant très longtemps, seule la surcouche était utilisée,
ce qui menait souvent à des vérifications de droits
inefficaces et à une gestion complexe de certaines
parties qui auraient pu être manipulées beaucoup plus simplement.
En plus de ça, les permissions liées à la plupart
des groupes se faisait de manière hardcodée :
plutôt que d'associer un groupe à un jeu de permission
et de faire une jointure en db sur les groupes de l'utilisateur
ayant cette permissions,
on conservait la clef primaire du groupe dans la config
et on vérifiait en dur dans le code que l'utilisateur
était un des groupes voulus.
Ce système possédait le triple désavantage de prendre énormément
de temps, d'être extrêmement limité (de fait, si tout est hardcodé,
on est obligé d'avoir le moins de groupes possibles pour que ça reste
gérable) et d'être désespérément dangereux (par exemple : fin novembre 2024,
une erreur dans le code a donné les accès à la création des cotisations
à tout le monde ; mi-octobre 2019, le calcul des permissions des etickets
pouvait faire tomber le site, cf.
[ce topic du forum](https://ae.utbm.fr/forum/topic/17943/?page=1msg2277272))
## Accès à toutes les ressources d'une table
Gérer ce genre d'accès (par exemple : voir toutes les nouvelles
ou pouvoir supprimer n'importe quelle photo)
est exactement le problème que le système de permissions de django résout.
Nous utilisons donc ce système dans ce genre de situations.
!!!note
Nous décrivons ci-dessous l'usage que nous faisons du système
de permissions de django,
mais la seule source d'information complète et pleinement fiable
sur le fonctionnement réel de ce système est
[la documentation de django](https://docs.djangoproject.com/fr/stable/topics/auth/default/).
### Permissions d'un modèle
Par défaut, django crée quatre permissions pour chaque table de la base de données :
- `add_<nom de la table>` : créer un objet dans cette table
- `view_<nom de la table>` : voir le contenu de la table
- `change_<nom de la table>` : éditer des objets de la table
- `delete_<nom de la table>` : supprimer des objets de la table
Ces permissions sont créées au même moment que le modèle.
Si la table existe en base de données, ces permissions existent aussi.
Il est également possible de rajouter nos propres permissions,
directement dans les options Meta du modèle.
Par exemple, prenons le modèle suivant :
```python
from django.db import models
class News(models.Model):
# ...
class Meta:
permissions = [
("moderate_news", "Can moderate news"),
("view_unmoderated_news", "Can view non-moderated news"),
]
```
Ce dernier aura les permissions : `view_news`, `add_news`, `change_news`,
`delete_news`, `moderate_news` et `view_unmoderated_news`.
### Utilisation des permissions d'un modèle
Pour vérifier qu'un utilisateur a une permission,
on utilise les fonctions suivantes :
- `User.has_perm(perm)` : retourne `True` si l'utilisateur
a la permission voulue, sinon `False`
- `User.has_perms([perm_a, perm_b, perm_c])` : retourne `True` si l'utilisateur
a toutes les permissions voulues, sinon `False`.
Ces fonctions attendent un string suivant le format :
`<nom de l'application>.<nom de la permission>`.
Par exemple, la permission pour vérifier qu'un utilisateur
peut modérer une nouvelle sera : `com.moderate_news`.
Ces fonctions sont utilisables aussi bien dans les templates Jinja
que dans le code Python :
=== "Jinja"
```jinja
{% if user.has_perm("com.moderate_news") %}
<form method="post" action="{{ url("com:news_moderate", news_id=387) }}">
<input type="submit" value="Modérer" />
</form>
{% endif %}
```
=== "Python"
```python
from com.models import News
from core.models import User
user = User.objects.get(username="bibou")
news = News.objects.get(id=387)
if user.has_perm("com.moderate_news"):
news.is_moderated = True
news.save()
else:
raise PermissionDenied
```
Pour utiliser ce système de permissions dans une class-based view
(c'est-à-dire la plus grande partie de nos vues),
Django met à disposition `PermissionRequiredMixin`,
qui restreint l'accès à la vue aux utilisateurs ayant
la ou les permissions requises.
Pour les vues sous forme de fonction, il y a le décorateur
`permission_required`.
=== "Class-Based View"
```python
from com.models import News
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.shortcuts import redirect
from django.urls import reverse
from django.views import View
from django.views.generic.detail import SingleObjectMixin
class NewsModerateView(PermissionRequiredMixin, SingleObjectMixin, View):
model = News
pk_url_kwarg = "news_id"
permission_required = "com.moderate_news"
# On peut aussi fournir plusieurs permissions, par exemple :
# permission_required = ["com.moderate_news", "com.delete_news"]
def post(self, request, *args, **kwargs):
# Si nous sommes ici, nous pouvons être certains que l'utilisateur
# a la permission requise
obj = self.get_object()
obj.is_moderated = True
obj.save()
return redirect(reverse("com:news_list"))
```
=== "Function-based view"
```python
from com.models import News
from django.contrib.auth.decorators import permission_required
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.views.decorators.http import require_POST
@permission_required("com.moderate_news")
@require_POST
def moderate_news(request, news_id: int):
# Si nous sommes ici, nous pouvons être certains que l'utilisateur
# a la permission requise
news = get_object_or_404(News, id=news_id)
news.is_moderated = True
news.save()
return redirect(reverse("com:news_list"))
```
## Accès à des éléments en particulier
### Accès à l'auteur de la ressource
Dans ce genre de cas, on peut identifier trois acteurs possibles :
- les administrateurs peuvent accéder à toutes les ressources,
y compris non-modérées
- l'auteur d'une ressource non-modérée peut y accéder
- Les autres utilisateurs ne peuvent pas voir les ressources
non-modérées dont ils ne sont pas l'auteur
Dans ce genre de cas, on souhaite donc accorder l'accès aux
utilisateurs qui ont la permission globale, selon le système
décrit plus haut, ou bien à l'auteur de la ressource.
Pour cela, nous avons le mixin `PermissionOrAuthorRequired`.
Ce dernier va effectuer les mêmes vérifications que `PermissionRequiredMixin`
puis, si l'utilisateur n'a pas la permission requise, vérifier
s'il est l'auteur de la ressource.
```python
from com.models import News
from core.auth.mixins import PermissionOrAuthorRequiredMixin
from django.views.generic import UpdateView
class NewsUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
model = News
pk_url_kwarg = "news_id"
permission_required = "com.change_news"
author_field = "author" # (1)!
```
1. Nom du champ du modèle utilisé comme clef étrangère vers l'auteur.
Par exemple, ici, la permission sera accordée si
l'utilisateur connecté correspond à l'utilisateur
désigné par `News.author`.
### Accès en fonction de règles plus complexes
Tout ce que nous avons décrit précédemment permet de couvrir
la plupart des cas simples.
Cependant, il arrivera souvent que les permissions attendues soient
plus complexes.
Dans ce genre de cas, on rentre entièrement dans notre surcouche.
#### Implémentation dans les modèles
La gestion de ce type de permissions se fait directement par modèle.
Il en existe trois niveaux :
- Éditer des propriétés de l'objet
- Éditer certaines valeurs l'objet
@ -47,28 +324,43 @@ Voici un exemple d'implémentation de ce système :
from core.models import User, Group
# Utilisation de la protection par fonctions
class Article(models.Model):
title = models.CharField(_("title"), max_length=100)
content = models.TextField(_("content"))
# Donne ou non les droits d'édition des propriétés de l'objet
# Un utilisateur dans le bureau AE aura tous les droits sur cet objet
def is_owned_by(self, user):
def is_owned_by(self, user): # (1)!
return user.is_board_member
# Donne ou non les droits d'édition de l'objet
# L'objet ne sera modifiable que par un utilisateur cotisant
def can_be_edited_by(self, user):
def can_be_edited_by(self, user): # (2)!
return user.is_subscribed
# Donne ou non les droits de vue de l'objet
# Ici, l'objet n'est visible que par un utilisateur connecté
def can_be_viewed_by(self, user):
def can_be_viewed_by(self, user): # (3)!
return not user.is_anonymous
```
1. Donne ou non les droits d'édition des propriétés de l'objet.
Ici, un utilisateur dans le bureau AE aura tous les droits sur cet objet
2. Donne ou non les droits d'édition de l'objet
Ici, l'objet ne sera modifiable que par un utilisateur cotisant
3. Donne ou non les droits de vue de l'objet
Ici, l'objet n'est visible que par un utilisateur connecté
!!!note
Dans cet exemple, nous utilisons des permissions très simples
pour que vous puissiez constater le squelette de ce système,
plutôt que la logique de validation dans ce cas particulier.
En réalité, il serait ici beaucoup plus approprié de
donner les permissions `com.delete_article` et
`com.change_article_properties` (en créant ce dernier
s'il n'existe pas encore) au groupe du bureau AE,
de donner également la permission `com.change_article`
au groupe `Cotisants` et enfin de restreindre l'accès
aux vues d'accès aux articles avec `LoginRequiredMixin`.
=== "Avec les groupes de permission"
```python
@ -83,15 +375,12 @@ Voici un exemple d'implémentation de ce système :
content = models.TextField(_("content"))
# relation one-to-many
# Groupe possédant l'objet
# Donne les droits d'édition des propriétés de l'objet
owner_group = models.ForeignKey(
owner_group = models.ForeignKey( # (1)!
Group, related_name="owned_articles", default=settings.SITH_GROUP_ROOT_ID
)
# relation many-to-many
# Tous les groupes qui seront ajouté dans ce champ auront les droits d'édition de l'objet
edit_groups = models.ManyToManyField(
edit_groups = models.ManyToManyField( # (2)!
Group,
related_name="editable_articles",
verbose_name=_("edit groups"),
@ -99,8 +388,7 @@ Voici un exemple d'implémentation de ce système :
)
# relation many-to-many
# Tous les groupes qui seront ajouté dans ce champ auront les droits de vue de l'objet
view_groups = models.ManyToManyField(
view_groups = models.ManyToManyField( # (3)!
Group,
related_name="viewable_articles",
verbose_name=_("view groups"),
@ -108,18 +396,25 @@ Voici un exemple d'implémentation de ce système :
)
```
### Appliquer les permissions
1. Groupe possédant l'objet
Donne les droits d'édition des propriétés de l'objet.
Il ne peut y avoir qu'un seul groupe `owner` par objet.
2. Tous les groupes ayant droit d'édition sur l'objet.
Il peut y avoir autant de groupes d'édition que l'on veut par objet.
3. Tous les groupes ayant droit de voir l'objet.
Il peut y avoir autant de groupes de vue que l'on veut par objet.
#### Dans un template
#### Application dans les templates
Il existe trois fonctions de base sur lesquelles
reposent les vérifications de permission.
Elles sont disponibles dans le contexte par défaut du
moteur de template et peuvent être utilisées à tout moment.
- [can_edit_prop(obj, user)][core.views.can_edit_prop] : équivalent de `obj.is_owned_by(user)`
- [can_edit(obj, user)][core.views.can_edit] : équivalent de `obj.can_be_edited_by(user)`
- [can_view(obj, user)][core.views.can_view] : équivalent de `obj.can_be_viewed_by(user)`
- [can_edit_prop(obj, user)][core.auth.mixins.can_edit_prop] : équivalent de `obj.is_owned_by(user)`
- [can_edit(obj, user)][core.auth.mixins.can_edit] : équivalent de `obj.can_be_edited_by(user)`
- [can_view(obj, user)][core.auth.mixins.can_view] : équivalent de `obj.can_be_viewed_by(user)`
Voici un exemple d'utilisation dans un template :
@ -130,7 +425,7 @@ Voici un exemple d'utilisation dans un template :
{% endif %}
```
#### Dans une vue
#### Application dans les vues
Généralement, les vérifications de droits dans les templates
se limitent aux urls à afficher puisqu'il
@ -138,7 +433,7 @@ ne faut normalement pas mettre de logique autre que d'affichage à l'intérieur
(en réalité, c'est un principe qu'on a beaucoup violé, mais promis on le fera plus).
C'est donc habituellement au niveau des vues que cela a lieu.
Notre système s'appuie sur un système de mixin
Pour cela, nous avons rajouté des mixins
à hériter lors de la création d'une vue basée sur une classe.
Ces mixins ne sont compatibles qu'avec les classes récupérant
un objet ou une liste d'objet.
@ -152,34 +447,60 @@ l'utilisateur recevra une liste vide d'objet.
Voici un exemple d'utilisation en reprenant l'objet Article crée précédemment :
```python
from django.views.generic import CreateView, ListView
from django.views.generic import CreateView, DetailView
from core.views import CanViewMixin, CanCreateMixin
from core.auth.mixins import CanViewMixin, CanCreateMixin
from com.models import WeekmailArticle
# Il est important de mettre le mixin avant la classe héritée de Django
# L'héritage multiple se fait de droite à gauche et les mixins ont besoin
# d'une classe de base pour fonctionner correctement.
class ArticlesListView(CanViewMixin, ListView):
model = WeekmailArticle
class ArticlesDetailView(CanViewMixin, DetailView):
model = WeekmailArticle
# Même chose pour une vue de création de l'objet Article
class ArticlesCreateView(CanCreateMixin, CreateView):
model = WeekmailArticle
model = WeekmailArticle
```
Les mixins suivants sont implémentés :
- [CanCreateMixin][core.views.CanCreateMixin] : l'utilisateur peut-il créer l'objet ?
- [CanEditPropMixin][core.views.CanEditPropMixin] : l'utilisateur peut-il éditer les propriétés de l'objet ?
- [CanEditMixin][core.views.CanEditMixin] : L'utilisateur peut-il éditer l'objet ?
- [CanViewMixin][core.views.CanViewMixin] : L'utilisateur peut-il voir l'objet ?
- [UserIsRootMixin][core.views.UserIsRootMixin] : L'utilisateur a-t-il les droit root ?
- [FormerSubscriberMixin][core.views.FormerSubscriberMixin] : L'utilisateur a-t-il déjà été cotisant ?
- [UserIsLoggedMixin][core.views.UserIsLoggedMixin] : L'utilisateur est-il connecté ?
(à éviter ; préférez `LoginRequiredMixin`, fourni par Django)
- [CanCreateMixin][core.auth.mixins.CanCreateMixin] : l'utilisateur peut-il créer l'objet ?
Ce mixin existe, mais est déprécié et ne doit plus être utilisé !
- [CanEditPropMixin][core.auth.mixins.CanEditPropMixin] : l'utilisateur peut-il éditer les propriétés de l'objet ?
- [CanEditMixin][core.auth.mixins.CanEditMixin] : L'utilisateur peut-il éditer l'objet ?
- [CanViewMixin][core.auth.mixins.CanViewMixin] : L'utilisateur peut-il voir l'objet ?
- [FormerSubscriberMixin][core.auth.mixins.FormerSubscriberMixin] : L'utilisateur a-t-il déjà été cotisant ?
!!!danger "CanCreateMixin"
L'usage de `CanCreateMixin` est dangereux et ne doit en aucun cas être
étendu.
La façon dont ce mixin marche est qu'il valide le formulaire
de création et crée l'objet sans le persister en base de données, puis
vérifie les droits sur cet objet non-persisté.
Le danger de ce système vient de multiples raisons :
- Les vérifications se faisant sur un objet non persisté,
l'utilisation de mécanismes nécessitant une persistance préalable
peut mener à des comportements indésirés, voire à des erreurs.
- Les développeurs de django ayant tendance à restreindre progressivement
les actions qui peuvent être faites sur des objets non-persistés,
les mises-à-jour de django deviennent plus compliquées.
- La vérification des droits ne se fait que dans les requêtes POST,
à la toute fin de la requête.
Tout ce qui arrive avant n'est absolument pas protégé.
Toute opération (même les suppressions et les créations) qui ont
lieu avant la persistance de l'objet seront appliquées,
même sans permission.
- Si un développeur du site fait l'erreur de surcharger
la méthode `form_valid` (ce qui est plutôt courant,
lorsqu'on veut accomplir certaines actions
quand un formulaire est valide), on peut se retrouver
dans une situation où l'objet est persisté sans aucune protection.
!!!danger "Performance"
@ -197,6 +518,76 @@ Les mixins suivants sont implémentés :
Mais sur les `ListView`, on peut arriver à des temps
de réponse extrêmement élevés.
### Filtrage des querysets
Récupérer tous les objets d'un queryset et vérifier pour chacun que
l'utilisateur a le droit de les voir peut-être excessivement
coûteux en ressources
(cf. l'encart ci-dessus).
Lorsqu'il est nécessaire de récupérer un certain nombre
d'objets depuis la base de données, il est donc préférable
de filtrer directement depuis le queryset.
Pour cela, certains modèles, tels que [Picture][sas.models.Picture]
peuvent être filtrés avec la méthode de queryset `viewable_by`.
Cette dernière s'utilise comme n'importe quelle autre méthode
de queryset :
```python
from sas.models import Picture
from core.models import User
user = User.objects.get(username="bibou")
pictures = Picture.objects.viewable_by(user)
```
Le résultat de la requête contiendra uniquement des éléments
que l'utilisateur sélectionné a effectivement le droit de voir.
Si vous désirez utiliser cette méthode sur un modèle
qui ne la possède pas, il est relativement facile de l'écrire :
```python
from typing import Self
from django.db import models
from core.models import User
class NewsQuerySet(models.QuerySet): # (1)!
def viewable_by(self, user: User) -> Self:
if user.has_perm("com.view_unmoderated_news"):
# si l'utilisateur peut tout voir, on retourne tout
return self
# sinon, on retourne les nouvelles modérées ou dont l'utilisateur
# est l'auteur
return self.filter(
models.Q(is_moderated=True)
| models.Q(author=user)
)
class News(models.Model):
is_moderated = models.BooleanField(default=False)
author = models.ForeignKey(User, on_delete=models.PROTECT)
# ...
objects = NewsQuerySet.as_manager() # (2)!
class Meta:
permissions = [("view_unmoderated_news", "Can view non moderated news")]
```
1. On crée un `QuerySet` maison, dans lequel on définit la méthode `viewable_by`
2. Puis, on attache ce `QuerySet` à notre modèle
!!!note
Pour plus d'informations sur la création de `QuerySet` personnalisés, voir
[la documentation de django](https://docs.djangoproject.com/fr/stable/topics/db/managers/)
## API
L'API utilise son propre système de permissions.

View File

@ -1,6 +1,7 @@
from typing import TYPE_CHECKING
from django import forms
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.db.models.query import QuerySet
@ -10,7 +11,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
from core.views import CanCreateMixin, CanEditMixin, CanViewMixin
from core.auth.mixins import CanCreateMixin, CanEditMixin, CanViewMixin
from core.views.forms import SelectDateTime
from core.views.widgets.markdown import MarkdownInput
from core.views.widgets.select import (
@ -300,7 +301,7 @@ class VoteFormView(CanCreateMixin, FormView):
# Create views
class CandidatureCreateView(CanCreateMixin, CreateView):
class CandidatureCreateView(LoginRequiredMixin, CreateView):
"""View dedicated to a cundidature creation."""
form_class = CandidateForm
@ -326,12 +327,13 @@ class CandidatureCreateView(CanCreateMixin, CreateView):
def form_valid(self, form):
"""Verify that the selected user is in candidate group."""
obj = form.instance
obj.election = Election.objects.get(id=self.election.id)
obj.user = obj.user if hasattr(obj, "user") else self.request.user
obj.election = self.election
if not hasattr(obj, "user"):
obj.user = self.request.user
if (obj.election.can_candidate(obj.user)) and (
obj.user == self.request.user or self.can_edit
):
return super(CreateView, self).form_valid(form)
return super().form_valid(form)
raise PermissionDenied
def get_context_data(self, **kwargs):
@ -343,22 +345,14 @@ class CandidatureCreateView(CanCreateMixin, CreateView):
return reverse_lazy("election:detail", kwargs={"election_id": self.election.id})
class ElectionCreateView(CanCreateMixin, CreateView):
class ElectionCreateView(PermissionRequiredMixin, CreateView):
model = Election
form_class = ElectionForm
template_name = "core/create.jinja"
def dispatch(self, request, *args, **kwargs):
if not request.user.is_subscribed:
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form):
"""Allow every user that had passed the dispatch to create an election."""
return super(CreateView, self).form_valid(form)
permission_required = "election.add_election"
def get_success_url(self, **kwargs):
return reverse_lazy("election:detail", kwargs={"election_id": self.object.id})
return reverse("election:detail", kwargs={"election_id": self.object.id})
class RoleCreateView(CanCreateMixin, CreateView):

View File

@ -43,7 +43,7 @@ from haystack.query import RelatedSearchQuerySet
from honeypot.decorators import check_honeypot
from club.widgets.select import AutoCompleteSelectClub
from core.views import (
from core.auth.mixins import (
CanCreateMixin,
CanEditMixin,
CanEditPropMixin,

View File

@ -27,12 +27,9 @@ from django.http import Http404, JsonResponse
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, View
from core.auth.mixins import CanViewMixin, FormerSubscriberMixin
from core.models import User
from core.views import (
CanViewMixin,
FormerSubscriberMixin,
UserTabsMixin,
)
from core.views import UserTabsMixin
from galaxy.models import Galaxy, GalaxyLane

View File

@ -19,6 +19,7 @@ from datetime import timezone as tz
from django import forms
from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db import transaction
from django.template import defaultfilters
from django.urls import reverse_lazy
@ -28,8 +29,8 @@ from django.views.generic import DetailView, ListView, TemplateView
from django.views.generic.edit import BaseFormView, CreateView, DeleteView, UpdateView
from club.models import Club
from core.auth.mixins import CanEditMixin, CanEditPropMixin, CanViewMixin
from core.models import Page, User
from core.views import CanCreateMixin, CanEditMixin, CanEditPropMixin, CanViewMixin
from counter.forms import GetUserForm
from counter.models import Counter, Customer, Selling
from launderette.models import Launderette, Machine, Slot, Token
@ -186,12 +187,13 @@ class LaunderetteEditView(CanEditPropMixin, UpdateView):
template_name = "core/edit.jinja"
class LaunderetteCreateView(CanCreateMixin, CreateView):
class LaunderetteCreateView(PermissionRequiredMixin, CreateView):
"""Create a new launderette."""
model = Launderette
fields = ["name"]
template_name = "core/create.jinja"
permission_required = "launderette.add_launderette"
def form_valid(self, form):
club = Club.objects.filter(
@ -492,12 +494,13 @@ class MachineDeleteView(CanEditPropMixin, DeleteView):
success_url = reverse_lazy("launderette:launderette_list")
class MachineCreateView(CanCreateMixin, CreateView):
class MachineCreateView(PermissionRequiredMixin, CreateView):
"""Create a new machine."""
model = Machine
fields = ["name", "launderette", "type"]
template_name = "core/create.jinja"
permission_required = "launderette.add_machine"
def get_initial(self):
ret = super().get_initial()

View File

@ -32,8 +32,9 @@ from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import FormView
from phonenumber_field.widgets import RegionalPhoneNumberWidget
from core.auth.mixins import FormerSubscriberMixin
from core.models import User
from core.views import FormerSubscriberMixin, search_user
from core.views import search_user
from core.views.forms import SelectDate
# Enum to select search type

View File

@ -98,7 +98,7 @@ nav:
- Champs de modèle: reference/core/model_fields.md
- reference/core/views.md
- reference/core/schemas.md
- reference/core/api_permissions.md
- reference/core/auth.md
- counter:
- reference/counter/models.md
- reference/counter/views.md

View File

@ -7,7 +7,7 @@ from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import NotFound
from ninja_extra.pagination import PageNumberPaginationExtra, PaginatedResponseSchema
from core.api_permissions import IsInGroup, IsRoot, IsSubscriber
from core.auth.api_permissions import IsInGroup, IsRoot, IsSubscriber
from pedagogy.models import UV
from pedagogy.schemas import SimpleUvSchema, UvFilterSchema, UvSchema
from pedagogy.utbm_api import find_uv

View File

@ -26,6 +26,7 @@ from django.conf import settings
from django.test import Client, TestCase
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from pytest_django.asserts import assertRedirects
from core.models import Notification, User
from pedagogy.models import UV, UVComment, UVCommentReport
@ -106,7 +107,7 @@ class TestUVCreation(TestCase):
def test_create_uv_unauthorized_fail(self):
# Test with anonymous user
response = self.client.post(self.create_uv_url, create_uv_template(0))
assert response.status_code == 403
assertRedirects(response, reverse("core:login") + f"?next={self.create_uv_url}")
# Test with subscribed user
self.client.force_login(self.sli)
@ -815,11 +816,11 @@ class TestUVCommentReportCreate(TestCase):
self.create_report_test("guy", success=False)
def test_create_report_anonymous_fail(self):
url = reverse("pedagogy:comment_report", kwargs={"comment_id": self.comment.id})
response = self.client.post(
reverse("pedagogy:comment_report", kwargs={"comment_id": self.comment.id}),
{"comment": self.comment.id, "reporter": 0, "reason": "C'est moche"},
url, {"comment": self.comment.id, "reporter": 0, "reason": "C'est moche"}
)
assert response.status_code == 403
assertRedirects(response, reverse("core:login") + f"?next={url}")
assert not UVCommentReport.objects.all().exists()
def test_notifications(self):

View File

@ -22,7 +22,7 @@
#
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.core.exceptions import PermissionDenied
from django.db.models import Exists, OuterRef
from django.shortcuts import get_object_or_404
@ -35,14 +35,9 @@ from django.views.generic import (
UpdateView,
)
from core.auth.mixins import CanEditPropMixin, CanViewMixin, FormerSubscriberMixin
from core.models import Notification, User
from core.views import (
CanCreateMixin,
CanEditPropMixin,
CanViewMixin,
DetailFormView,
FormerSubscriberMixin,
)
from core.views import DetailFormView
from pedagogy.forms import (
UVCommentForm,
UVCommentModerationForm,
@ -51,8 +46,6 @@ from pedagogy.forms import (
)
from pedagogy.models import UV, UVComment, UVCommentReport
# Acutal views
class UVDetailFormView(CanViewMixin, DetailFormView):
"""Display every comment of an UV and detailed infos about it.
@ -138,12 +131,13 @@ class UVGuideView(LoginRequiredMixin, FormerSubscriberMixin, TemplateView):
}
class UVCommentReportCreateView(CanCreateMixin, CreateView):
class UVCommentReportCreateView(PermissionRequiredMixin, CreateView):
"""Create a new report for an inapropriate comment."""
model = UVCommentReport
form_class = UVCommentReportForm
template_name = "core/edit.jinja"
permission_required = "pedagogy.add_uvcommentreport"
def dispatch(self, request, *args, **kwargs):
self.uv_comment = get_object_or_404(UVComment, pk=kwargs["comment_id"])
@ -204,12 +198,13 @@ class UVModerationFormView(FormView):
return reverse_lazy("pedagogy:moderation")
class UVCreateView(CanCreateMixin, CreateView):
class UVCreateView(PermissionRequiredMixin, CreateView):
"""Add a new UV (Privileged)."""
model = UV
form_class = UVForm
template_name = "pedagogy/uv_edit.jinja"
permission_required = "pedagogy.add_uv"
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()

View File

@ -12,7 +12,7 @@ from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema
from pydantic import NonNegativeInt
from core.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
from core.auth.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
from core.models import Notification, User
from sas.models import Album, PeoplePictureRelation, Picture
from sas.schemas import (

View File

@ -23,8 +23,8 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, TemplateView
from django.views.generic.edit import FormMixin, FormView, UpdateView
from core.auth.mixins import CanEditMixin, CanViewMixin
from core.models import SithFile, User
from core.views import CanEditMixin, CanViewMixin
from core.views.files import FileView, send_file
from sas.forms import (
AlbumEditForm,

View File

@ -155,13 +155,12 @@ TEMPLATES = [
"add_attr": "core.templatetags.renderer.add_attr",
},
"globals": {
"can_edit_prop": "core.views.can_edit_prop",
"can_edit": "core.views.can_edit",
"can_view": "core.views.can_view",
"can_edit_prop": "core.auth.mixins.can_edit_prop",
"can_edit": "core.auth.mixins.can_edit",
"can_view": "core.auth.mixins.can_view",
"settings": "sith.settings",
"Launderette": "launderette.models.Launderette",
"Counter": "counter.models.Counter",
"ProductType": "counter.models.ProductType",
"timezone": "django.utils.timezone",
"get_sith": "com.views.sith",
"get_language": "django.utils.translation.get_language",
@ -292,9 +291,9 @@ STORAGES = {
# Auth configuration
AUTH_USER_MODEL = "core.User"
AUTH_ANONYMOUS_MODEL = "core.models.AnonymousUser"
AUTHENTICATION_BACKENDS = ["core.auth_backends.SithModelBackend"]
LOGIN_URL = "/login"
LOGOUT_URL = "/logout"
AUTHENTICATION_BACKENDS = ["core.auth.backends.SithModelBackend"]
LOGIN_URL = "/login/"
LOGOUT_URL = "/logout/"
LOGIN_REDIRECT_URL = "/"
DEFAULT_FROM_EMAIL = "bibou@git.an"
SITH_COM_EMAIL = "bibou_com@git.an"

View File

@ -38,16 +38,15 @@ from django.views.generic import DetailView, RedirectView, TemplateView, View
from django.views.generic.edit import CreateView, DeleteView, UpdateView
from club.models import Club
from core.models import User
from core.views import (
from core.auth.mixins import (
CanCreateMixin,
CanEditMixin,
CanEditPropMixin,
CanViewMixin,
QuickNotifMixin,
TabedViewMixin,
)
from core.models import User
from core.views.forms import SelectDate
from core.views.mixins import QuickNotifMixin, TabedViewMixin
from core.views.widgets.select import AutoCompleteSelectUser
from trombi.models import Trombi, TrombiClubMembership, TrombiComment, TrombiUser