Improve permission documentation

This commit is contained in:
imperosol 2025-01-13 18:02:47 +01:00
parent d0b1a49300
commit 9b5f08e13c
2 changed files with 440 additions and 42 deletions

View File

@ -157,7 +157,9 @@ il est automatiquement ajouté au groupe des membres
du club. du club.
Lorsqu'il quitte le club, il est retiré du groupe. 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 : Les groupes les plus notables gérables par les administrateurs du site sont :
@ -168,15 +170,45 @@ Les groupes les plus notables gérables par les administrateurs du site sont :
- `SAS admin` : les administrateurs du SAS - `SAS admin` : les administrateurs du SAS
- `Forum admin` : les administrateurs du forum - `Forum admin` : les administrateurs du forum
- `Pedagogy admin` : les administrateurs de la pédagogie (guide des UVs) - `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 : En plus de ces groupes, on peut noter :
- `Public` : tous les utilisateurs du site - `Public` : tous les utilisateurs du site.
- `Subscribers` : tous les cotisants du site Un utilisateur est automatiquement ajouté à ce group
- `Old subscribers` : tous les anciens cotisants 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
### 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 Les permissions attendues sur le site sont relativement spécifiques.
intégré à Django tel quel. Lors de la conception du Sith, ce qui paraissait le L'accès à une ressource peut se faire selon un certain nombre
plus simple à l'époque était de concevoir un système maison afin de se calquer de paramètres différents :
sur ce que faisait l'ancien site.
### 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. `L'appartenance à un groupe`
Il existe trois niveaux de permission : : 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 des propriétés de l'objet
- Éditer certaines valeurs 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 from core.models import User, Group
# Utilisation de la protection par fonctions
class Article(models.Model): class Article(models.Model):
title = models.CharField(_("title"), max_length=100) title = models.CharField(_("title"), max_length=100)
content = models.TextField(_("content")) content = models.TextField(_("content"))
# Donne ou non les droits d'édition des propriétés de l'objet def is_owned_by(self, user): # (1)!
# Un utilisateur dans le bureau AE aura tous les droits sur cet objet
def is_owned_by(self, user):
return user.is_board_member return user.is_board_member
# Donne ou non les droits d'édition de l'objet def can_be_edited_by(self, user): # (2)!
# L'objet ne sera modifiable que par un utilisateur cotisant
def can_be_edited_by(self, user):
return user.is_subscribed return user.is_subscribed
# Donne ou non les droits de vue de l'objet def can_be_viewed_by(self, user): # (3)!
# Ici, l'objet n'est visible que par un utilisateur connecté
def can_be_viewed_by(self, user):
return not user.is_anonymous 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" === "Avec les groupes de permission"
```python ```python
@ -83,15 +375,12 @@ Voici un exemple d'implémentation de ce système :
content = models.TextField(_("content")) content = models.TextField(_("content"))
# relation one-to-many # relation one-to-many
# Groupe possédant l'objet owner_group = models.ForeignKey( # (1)!
# Donne les droits d'édition des propriétés de l'objet
owner_group = models.ForeignKey(
Group, related_name="owned_articles", default=settings.SITH_GROUP_ROOT_ID Group, related_name="owned_articles", default=settings.SITH_GROUP_ROOT_ID
) )
# relation many-to-many # 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( # (2)!
edit_groups = models.ManyToManyField(
Group, Group,
related_name="editable_articles", related_name="editable_articles",
verbose_name=_("edit groups"), verbose_name=_("edit groups"),
@ -99,8 +388,7 @@ Voici un exemple d'implémentation de ce système :
) )
# relation many-to-many # 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( # (3)!
view_groups = models.ManyToManyField(
Group, Group,
related_name="viewable_articles", related_name="viewable_articles",
verbose_name=_("view groups"), verbose_name=_("view groups"),
@ -108,9 +396,16 @@ 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 Il existe trois fonctions de base sur lesquelles
reposent les vérifications de permission. reposent les vérifications de permission.
@ -130,7 +425,7 @@ Voici un exemple d'utilisation dans un template :
{% endif %} {% endif %}
``` ```
#### Dans une vue #### Application dans les vues
Généralement, les vérifications de droits dans les templates Généralement, les vérifications de droits dans les templates
se limitent aux urls à afficher puisqu'il 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). (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. 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. à 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 Ces mixins ne sont compatibles qu'avec les classes récupérant
un objet ou une liste d'objet. un objet ou une liste d'objet.
@ -152,16 +447,17 @@ l'utilisateur recevra une liste vide d'objet.
Voici un exemple d'utilisation en reprenant l'objet Article crée précédemment : Voici un exemple d'utilisation en reprenant l'objet Article crée précédemment :
```python ```python
from django.views.generic import CreateView, ListView from django.views.generic import CreateView, DetailView
from core.auth.mixins import CanViewMixin, CanCreateMixin from core.auth.mixins import CanViewMixin, CanCreateMixin
from com.models import WeekmailArticle from com.models import WeekmailArticle
# Il est important de mettre le mixin avant la classe héritée de Django # 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 # L'héritage multiple se fait de droite à gauche et les mixins ont besoin
# d'une classe de base pour fonctionner correctement. # d'une classe de base pour fonctionner correctement.
class ArticlesListView(CanViewMixin, ListView): class ArticlesDetailView(CanViewMixin, DetailView):
model = WeekmailArticle model = WeekmailArticle
@ -221,6 +517,76 @@ Les mixins suivants sont implémentés :
Mais sur les `ListView`, on peut arriver à des temps Mais sur les `ListView`, on peut arriver à des temps
de réponse extrêmement élevés. 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 ## API
L'API utilise son propre système de permissions. L'API utilise son propre système de permissions.