oxutils 0.1.6__py3-none-any.whl → 0.1.12__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- oxutils/__init__.py +2 -2
- oxutils/audit/migrations/0001_initial.py +2 -2
- oxutils/audit/models.py +2 -2
- oxutils/constants.py +6 -0
- oxutils/jwt/auth.py +150 -1
- oxutils/jwt/models.py +81 -0
- oxutils/jwt/tokens.py +69 -0
- oxutils/jwt/utils.py +45 -0
- oxutils/logger/__init__.py +10 -0
- oxutils/logger/receivers.py +10 -6
- oxutils/logger/settings.py +2 -2
- oxutils/models/base.py +102 -0
- oxutils/models/fields.py +79 -0
- oxutils/oxiliere/apps.py +9 -1
- oxutils/oxiliere/authorization.py +45 -0
- oxutils/oxiliere/caches.py +13 -11
- oxutils/oxiliere/checks.py +31 -0
- oxutils/oxiliere/constants.py +3 -0
- oxutils/oxiliere/context.py +16 -0
- oxutils/oxiliere/exceptions.py +16 -0
- oxutils/oxiliere/management/commands/grant_tenant_owners.py +19 -0
- oxutils/oxiliere/management/commands/init_oxiliere_system.py +30 -11
- oxutils/oxiliere/middleware.py +65 -11
- oxutils/oxiliere/models.py +146 -9
- oxutils/oxiliere/permissions.py +28 -35
- oxutils/oxiliere/schemas.py +16 -6
- oxutils/oxiliere/signals.py +5 -0
- oxutils/oxiliere/utils.py +36 -1
- oxutils/pagination/cursor.py +367 -0
- oxutils/permissions/__init__.py +0 -0
- oxutils/permissions/actions.py +57 -0
- oxutils/permissions/admin.py +3 -0
- oxutils/permissions/apps.py +10 -0
- oxutils/permissions/caches.py +19 -0
- oxutils/permissions/checks.py +188 -0
- oxutils/permissions/constants.py +0 -0
- oxutils/permissions/controllers.py +344 -0
- oxutils/permissions/exceptions.py +60 -0
- oxutils/permissions/management/__init__.py +0 -0
- oxutils/permissions/management/commands/__init__.py +0 -0
- oxutils/permissions/management/commands/load_permission_preset.py +112 -0
- oxutils/permissions/migrations/0001_initial.py +112 -0
- oxutils/permissions/migrations/0002_alter_grant_role.py +19 -0
- oxutils/permissions/migrations/0003_alter_grant_options_alter_group_options_and_more.py +33 -0
- oxutils/permissions/migrations/__init__.py +0 -0
- oxutils/permissions/models.py +171 -0
- oxutils/permissions/perms.py +95 -0
- oxutils/permissions/queryset.py +92 -0
- oxutils/permissions/schemas.py +276 -0
- oxutils/permissions/services.py +663 -0
- oxutils/permissions/tests.py +3 -0
- oxutils/permissions/utils.py +628 -0
- oxutils/settings.py +14 -194
- oxutils/users/apps.py +1 -1
- oxutils/users/migrations/0001_initial.py +47 -0
- oxutils/users/migrations/0002_alter_user_first_name_alter_user_last_name.py +23 -0
- oxutils/users/models.py +2 -0
- oxutils/utils.py +25 -0
- {oxutils-0.1.6.dist-info → oxutils-0.1.12.dist-info}/METADATA +14 -11
- oxutils-0.1.12.dist-info/RECORD +122 -0
- oxutils/jwt/client.py +0 -123
- oxutils/jwt/constants.py +0 -1
- oxutils/s3/settings.py +0 -34
- oxutils/s3/storages.py +0 -130
- oxutils-0.1.6.dist-info/RECORD +0 -88
- /oxutils/{s3 → pagination}/__init__.py +0 -0
- {oxutils-0.1.6.dist-info → oxutils-0.1.12.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
from typing import Optional, Any
|
|
2
|
+
from django.db.models import Q
|
|
3
|
+
from django.db import transaction
|
|
4
|
+
from django.contrib.auth.models import AbstractBaseUser
|
|
5
|
+
|
|
6
|
+
from .models import Grant, RoleGrant, Group, UserGroup, Role
|
|
7
|
+
from .actions import expand_actions
|
|
8
|
+
from .exceptions import (
|
|
9
|
+
RoleNotFoundException,
|
|
10
|
+
GroupNotFoundException,
|
|
11
|
+
GrantNotFoundException,
|
|
12
|
+
GroupAlreadyAssignedException
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@transaction.atomic
|
|
19
|
+
def assign_role(
|
|
20
|
+
user: AbstractBaseUser,
|
|
21
|
+
role: str,
|
|
22
|
+
*,
|
|
23
|
+
by: Optional[AbstractBaseUser] = None,
|
|
24
|
+
user_group: Optional[UserGroup] = None
|
|
25
|
+
) -> None:
|
|
26
|
+
"""
|
|
27
|
+
Assigne un rôle à un utilisateur en créant ou mettant à jour les grants correspondants.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
user: L'utilisateur à qui assigner le rôle
|
|
31
|
+
role: Le slug du rôle à assigner
|
|
32
|
+
by: L'utilisateur qui effectue l'assignation (pour traçabilité)
|
|
33
|
+
user_group: Le UserGroup associé si le rôle est assigné via un groupe
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
RoleNotFoundException: Si le rôle n'existe pas
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
role_obj = Role.objects.get(slug=role)
|
|
40
|
+
except Role.DoesNotExist:
|
|
41
|
+
raise RoleNotFoundException(detail=f"Le rôle '{role}' n'existe pas")
|
|
42
|
+
|
|
43
|
+
# Filtrer les RoleGrants selon le groupe si fourni
|
|
44
|
+
if user_group:
|
|
45
|
+
# Si assigné via un groupe, utiliser les RoleGrants spécifiques au groupe ou génériques
|
|
46
|
+
role_grants = RoleGrant.objects.filter(
|
|
47
|
+
role__slug=role
|
|
48
|
+
).filter(
|
|
49
|
+
Q(group=user_group.group) | Q(group__isnull=True)
|
|
50
|
+
)
|
|
51
|
+
else:
|
|
52
|
+
# Si assigné directement, utiliser uniquement les RoleGrants génériques
|
|
53
|
+
role_grants = RoleGrant.objects.filter(role__slug=role, group__isnull=True)
|
|
54
|
+
|
|
55
|
+
for rg in role_grants:
|
|
56
|
+
Grant.objects.update_or_create(
|
|
57
|
+
user=user,
|
|
58
|
+
scope=rg.scope,
|
|
59
|
+
role=role_obj,
|
|
60
|
+
defaults={
|
|
61
|
+
"actions": expand_actions(rg.actions),
|
|
62
|
+
"context": rg.context,
|
|
63
|
+
"user_group": user_group,
|
|
64
|
+
"created_by": by,
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def revoke_role(user: AbstractBaseUser, role: str) -> tuple[int, dict[str, int]]:
|
|
69
|
+
"""
|
|
70
|
+
Révoque un rôle d'un utilisateur en supprimant tous les grants associés.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
user: L'utilisateur dont on révoque le rôle
|
|
74
|
+
role: Le slug du rôle à révoquer
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Tuple contenant le nombre d'objets supprimés et un dictionnaire des types supprimés
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
RoleNotFoundException: Si le rôle n'existe pas
|
|
81
|
+
"""
|
|
82
|
+
try:
|
|
83
|
+
role_obj = Role.objects.get(slug=role)
|
|
84
|
+
except Role.DoesNotExist:
|
|
85
|
+
raise RoleNotFoundException(detail=f"Le rôle '{role}' n'existe pas")
|
|
86
|
+
|
|
87
|
+
return Grant.objects.filter(
|
|
88
|
+
user__pk=user.pk,
|
|
89
|
+
role__slug=role
|
|
90
|
+
).delete()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@transaction.atomic
|
|
94
|
+
def assign_group(user: AbstractBaseUser, group: str, by: Optional[AbstractBaseUser] = None) -> UserGroup:
|
|
95
|
+
"""
|
|
96
|
+
Assigne tous les rôles d'un groupe à un utilisateur.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
user: L'utilisateur à qui assigner le groupe
|
|
100
|
+
group: Le slug du groupe à assigner
|
|
101
|
+
by: L'utilisateur qui effectue l'assignation (pour traçabilité)
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
L'objet UserGroup créé ou existant
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
GroupNotFoundException: Si le groupe n'existe pas
|
|
108
|
+
GroupAlreadyAssignedException: Si le groupe est déjà assigné
|
|
109
|
+
"""
|
|
110
|
+
if UserGroup.objects.filter(user=user, group__slug=group).exists():
|
|
111
|
+
raise GroupAlreadyAssignedException(
|
|
112
|
+
detail=f"Le groupe '{group}' est déjà assigné à l'utilisateur"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
_group: Group = Group.objects.get(slug=group)
|
|
117
|
+
except Group.DoesNotExist:
|
|
118
|
+
raise GroupNotFoundException(detail=f"Le groupe '{group}' n'existe pas")
|
|
119
|
+
|
|
120
|
+
# Créer le UserGroup d'abord
|
|
121
|
+
user_group, created = UserGroup.objects.get_or_create(user=user, group=_group)
|
|
122
|
+
|
|
123
|
+
# Assigner tous les rôles du groupe avec le lien vers UserGroup
|
|
124
|
+
for role in _group.roles.all():
|
|
125
|
+
assign_role(user, role.slug, by=by, user_group=user_group)
|
|
126
|
+
|
|
127
|
+
return user_group
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@transaction.atomic
|
|
131
|
+
def revoke_group(user: AbstractBaseUser, group: str) -> tuple[int, dict[str, int]]:
|
|
132
|
+
"""
|
|
133
|
+
Révoque tous les rôles d'un groupe d'un utilisateur.
|
|
134
|
+
Supprime tous les grants liés au UserGroup et le UserGroup lui-même.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
user: L'utilisateur dont on révoque le groupe
|
|
138
|
+
group: Le slug du groupe à révoquer
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Tuple contenant le nombre d'objets supprimés et un dictionnaire des types supprimés
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
GroupNotFoundException: Si le groupe n'existe pas
|
|
145
|
+
GroupNotFoundException: Si le groupe n'est pas assigné à l'utilisateur
|
|
146
|
+
"""
|
|
147
|
+
try:
|
|
148
|
+
_group: Group = Group.objects.get(slug=group)
|
|
149
|
+
except Group.DoesNotExist:
|
|
150
|
+
raise GroupNotFoundException(detail=f"Le groupe '{group}' n'existe pas")
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
user_group = UserGroup.objects.get(user=user, group=_group)
|
|
154
|
+
except UserGroup.DoesNotExist:
|
|
155
|
+
raise GroupNotFoundException(
|
|
156
|
+
detail=f"Le groupe '{group}' n'est pas assigné à l'utilisateur"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Supprimer tous les grants liés à ce UserGroup
|
|
160
|
+
grants_deleted, grants_info = Grant.objects.filter(
|
|
161
|
+
user=user,
|
|
162
|
+
user_group=user_group
|
|
163
|
+
).delete()
|
|
164
|
+
|
|
165
|
+
# Supprimer le UserGroup
|
|
166
|
+
user_group.delete()
|
|
167
|
+
|
|
168
|
+
return grants_deleted, grants_info
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@transaction.atomic
|
|
172
|
+
def override_grant(
|
|
173
|
+
user: AbstractBaseUser,
|
|
174
|
+
scope: str,
|
|
175
|
+
remove_actions: list[str]
|
|
176
|
+
) -> None:
|
|
177
|
+
"""
|
|
178
|
+
Modifie un grant existant en retirant certaines actions.
|
|
179
|
+
Si toutes les actions sont retirées, le grant est supprimé.
|
|
180
|
+
Le grant devient personnalisé (role=None) après modification.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
user: L'utilisateur dont on modifie le grant
|
|
184
|
+
scope: Le scope du grant à modifier
|
|
185
|
+
remove_actions: Liste des actions à retirer (seront expandées)
|
|
186
|
+
|
|
187
|
+
Raises:
|
|
188
|
+
GrantNotFoundException: Si le grant n'existe pas
|
|
189
|
+
"""
|
|
190
|
+
grant: Optional[Grant] = Grant.objects.select_related("user_group", "role").filter(user__pk=user.pk, scope=scope).first()
|
|
191
|
+
if not grant:
|
|
192
|
+
raise GrantNotFoundException(
|
|
193
|
+
detail=f"Aucun grant trouvé pour l'utilisateur sur le scope '{scope}'"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Travailler avec les actions expandées du grant
|
|
197
|
+
current_actions: set[str] = set(grant.actions)
|
|
198
|
+
# Ne PAS expander les actions à retirer - on retire seulement ce qui est demandé
|
|
199
|
+
actions_to_remove: set[str] = set(remove_actions)
|
|
200
|
+
|
|
201
|
+
# Retirer les actions demandées des actions actuelles
|
|
202
|
+
remaining_actions = current_actions - actions_to_remove
|
|
203
|
+
|
|
204
|
+
# Si plus d'actions, supprimer le grant
|
|
205
|
+
if not remaining_actions:
|
|
206
|
+
user_group = grant.user_group
|
|
207
|
+
grant.delete()
|
|
208
|
+
|
|
209
|
+
# Si le grant était lié à un UserGroup, vérifier s'il reste des grants pour ce groupe
|
|
210
|
+
if user_group:
|
|
211
|
+
remaining_grants = Grant.objects.filter(
|
|
212
|
+
user=user,
|
|
213
|
+
user_group=user_group
|
|
214
|
+
).exists()
|
|
215
|
+
|
|
216
|
+
# Si plus aucun grant lié à ce UserGroup, supprimer le UserGroup
|
|
217
|
+
if not remaining_grants:
|
|
218
|
+
user_group.delete()
|
|
219
|
+
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
# Mettre à jour le grant avec les nouvelles actions (garder la forme expandée)
|
|
223
|
+
grant.actions = sorted(remaining_actions)
|
|
224
|
+
grant.role = None # Le grant devient personnalisé
|
|
225
|
+
grant.save(update_fields=["actions", "role", "updated_at"])
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@transaction.atomic
|
|
229
|
+
def group_sync(group_slug: str) -> dict[str, int]:
|
|
230
|
+
"""
|
|
231
|
+
Synchronise les grants de tous les utilisateurs d'un groupe après modification des RoleGrants.
|
|
232
|
+
Réapplique tous les rôles du groupe pour assurer la cohérence des permissions héritées.
|
|
233
|
+
|
|
234
|
+
Cette fonction doit être appelée après :
|
|
235
|
+
- Création/modification/suppression d'un RoleGrant lié à un groupe
|
|
236
|
+
- Ajout/suppression d'un rôle dans un groupe
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
group_slug: Le slug du groupe à synchroniser
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
Dictionnaire avec les statistiques:
|
|
243
|
+
{
|
|
244
|
+
"users_synced": nombre d'utilisateurs synchronisés,
|
|
245
|
+
"grants_updated": nombre de grants mis à jour/créés
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
Raises:
|
|
249
|
+
GroupNotFoundException: Si le groupe n'existe pas
|
|
250
|
+
|
|
251
|
+
Example:
|
|
252
|
+
>>> # Après modification d'un RoleGrant
|
|
253
|
+
>>> group_sync("admins")
|
|
254
|
+
{"users_synced": 5, "grants_updated": 15}
|
|
255
|
+
"""
|
|
256
|
+
try:
|
|
257
|
+
group = Group.objects.prefetch_related('roles').get(slug=group_slug)
|
|
258
|
+
except Group.DoesNotExist:
|
|
259
|
+
raise GroupNotFoundException(detail=f"Le groupe '{group_slug}' n'existe pas")
|
|
260
|
+
|
|
261
|
+
# Récupérer tous les UserGroups liés à ce groupe
|
|
262
|
+
user_groups = UserGroup.objects.filter(group=group).select_related('user')
|
|
263
|
+
|
|
264
|
+
stats = {
|
|
265
|
+
"users_synced": 0,
|
|
266
|
+
"grants_updated": 0
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
# Pour chaque utilisateur du groupe
|
|
270
|
+
for user_group in user_groups:
|
|
271
|
+
user = user_group.user
|
|
272
|
+
|
|
273
|
+
# Récupérer les scopes avec des grants personnalisés (role=None) pour cet utilisateur et ce UserGroup
|
|
274
|
+
# Ces scopes doivent être exclus de la synchronisation
|
|
275
|
+
overridden_scopes = set(
|
|
276
|
+
Grant.objects.filter(
|
|
277
|
+
user=user,
|
|
278
|
+
user_group=user_group,
|
|
279
|
+
role__isnull=True
|
|
280
|
+
).values_list('scope', flat=True)
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# Supprimer uniquement les grants liés à ce UserGroup qui ont un rôle
|
|
284
|
+
# Les grants avec role=None sont des grants personnalisés (overridés) et doivent être préservés
|
|
285
|
+
deleted_count, _ = Grant.objects.filter(
|
|
286
|
+
user=user,
|
|
287
|
+
user_group=user_group,
|
|
288
|
+
role__isnull=False # Ne supprimer que les grants avec un rôle
|
|
289
|
+
).delete()
|
|
290
|
+
|
|
291
|
+
# Préparer les grants à créer en bulk
|
|
292
|
+
grants_to_create = []
|
|
293
|
+
|
|
294
|
+
# Réassigner tous les rôles du groupe
|
|
295
|
+
for role in group.roles.all():
|
|
296
|
+
# Récupérer les RoleGrants pour ce rôle (spécifiques au groupe + génériques)
|
|
297
|
+
role_grants = RoleGrant.objects.filter(
|
|
298
|
+
role=role
|
|
299
|
+
).filter(
|
|
300
|
+
Q(group=group) | Q(group__isnull=True)
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
# Préparer les grants correspondants, en excluant les scopes overridés
|
|
304
|
+
for rg in role_grants:
|
|
305
|
+
# Ignorer ce scope s'il a un grant personnalisé
|
|
306
|
+
if rg.scope in overridden_scopes:
|
|
307
|
+
continue
|
|
308
|
+
|
|
309
|
+
grants_to_create.append(
|
|
310
|
+
Grant(
|
|
311
|
+
user=user,
|
|
312
|
+
scope=rg.scope,
|
|
313
|
+
role=role,
|
|
314
|
+
actions=expand_actions(rg.actions),
|
|
315
|
+
context=rg.context,
|
|
316
|
+
user_group=user_group,
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# Créer tous les grants en une seule requête
|
|
321
|
+
if grants_to_create:
|
|
322
|
+
Grant.objects.bulk_create(
|
|
323
|
+
grants_to_create,
|
|
324
|
+
update_conflicts=True,
|
|
325
|
+
unique_fields=["user", "scope", "role", "user_group"],
|
|
326
|
+
update_fields=["actions", "context", "updated_at"]
|
|
327
|
+
)
|
|
328
|
+
stats["grants_updated"] += len(grants_to_create)
|
|
329
|
+
|
|
330
|
+
stats["users_synced"] += 1
|
|
331
|
+
|
|
332
|
+
return stats
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def check(
|
|
336
|
+
user: AbstractBaseUser,
|
|
337
|
+
scope: str,
|
|
338
|
+
required: list[str],
|
|
339
|
+
group: Optional[str] = None,
|
|
340
|
+
**context: Any
|
|
341
|
+
) -> bool:
|
|
342
|
+
"""
|
|
343
|
+
Vérifie si un utilisateur possède les permissions requises pour un scope donné.
|
|
344
|
+
Utilise l'opérateur PostgreSQL @> (contains) pour vérifier que toutes les actions
|
|
345
|
+
requises sont présentes dans le grant.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
user: L'utilisateur dont on vérifie les permissions
|
|
349
|
+
scope: Le scope à vérifier (ex: 'articles', 'users', 'comments')
|
|
350
|
+
required: Liste des actions requises (ex: ['r'], ['w', 'r'], ['d'])
|
|
351
|
+
group: Slug du groupe optionnel pour filtrer les grants par groupe
|
|
352
|
+
**context: Contexte additionnel pour filtrer les grants (clés JSON)
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
True si l'utilisateur possède toutes les actions requises, False sinon
|
|
356
|
+
|
|
357
|
+
Example:
|
|
358
|
+
>>> # Vérifier si l'utilisateur peut lire les articles
|
|
359
|
+
>>> check(user, 'articles', ['r'])
|
|
360
|
+
True
|
|
361
|
+
>>> # Vérifier avec contexte
|
|
362
|
+
>>> check(user, 'articles', ['w'], tenant_id=123)
|
|
363
|
+
False
|
|
364
|
+
>>> # Vérifier dans le contexte d'un groupe spécifique
|
|
365
|
+
>>> check(user, 'articles', ['w'], group='staff')
|
|
366
|
+
True
|
|
367
|
+
|
|
368
|
+
Note:
|
|
369
|
+
Les actions sont automatiquement expandées lors de la création du grant,
|
|
370
|
+
donc vérifier ['w'] vérifiera aussi ['r'] implicitement.
|
|
371
|
+
"""
|
|
372
|
+
# Construire le filtre de base
|
|
373
|
+
grant_filter = Q(
|
|
374
|
+
user__pk=user.pk,
|
|
375
|
+
scope=scope,
|
|
376
|
+
actions__contains=list(required),
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
# Filtrer par groupe si spécifié
|
|
380
|
+
if group:
|
|
381
|
+
grant_filter &= Q(user_group__group__slug=group)
|
|
382
|
+
|
|
383
|
+
# Ajouter les filtres de contexte si fournis
|
|
384
|
+
if context:
|
|
385
|
+
grant_filter &= Q(context__contains=context)
|
|
386
|
+
|
|
387
|
+
# Vérifier l'existence d'un grant correspondant
|
|
388
|
+
return Grant.objects.filter(grant_filter).exists()
|
|
389
|
+
|
|
390
|
+
def str_check(user: AbstractBaseUser, perm: str, **context: Any) -> bool:
|
|
391
|
+
"""
|
|
392
|
+
Vérifie si un utilisateur possède les permissions requises à partir d'une chaîne formatée.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
user: L'utilisateur dont on vérifie les permissions
|
|
396
|
+
perm: Chaîne de permission au format "<scope>:<actions>:<group>?key=value&key2=value2"
|
|
397
|
+
- scope: Le scope à vérifier (ex: 'articles')
|
|
398
|
+
- actions: Actions requises (ex: 'rw', 'r', 'rwdx')
|
|
399
|
+
- group: (Optionnel) Slug du groupe
|
|
400
|
+
- query params: (Optionnel) Contexte sous forme de query parameters
|
|
401
|
+
**context: Contexte additionnel pour filtrer les grants (fusionné avec les query params)
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
True si l'utilisateur possède les permissions requises, False sinon
|
|
405
|
+
|
|
406
|
+
Example:
|
|
407
|
+
>>> # Vérifier lecture sur articles
|
|
408
|
+
>>> str_check(user, 'articles:r')
|
|
409
|
+
True
|
|
410
|
+
>>> # Vérifier écriture sur articles dans le groupe staff
|
|
411
|
+
>>> str_check(user, 'articles:w:staff')
|
|
412
|
+
True
|
|
413
|
+
>>> # Avec contexte via query params
|
|
414
|
+
>>> str_check(user, 'articles:w?tenant_id=123&status=published')
|
|
415
|
+
False
|
|
416
|
+
>>> # Avec groupe et contexte
|
|
417
|
+
>>> str_check(user, 'articles:w:staff?tenant_id=123')
|
|
418
|
+
True
|
|
419
|
+
>>> # Contexte mixte (query params + kwargs)
|
|
420
|
+
>>> str_check(user, 'articles:w?tenant_id=123', level=2)
|
|
421
|
+
False
|
|
422
|
+
"""
|
|
423
|
+
from .caches import cache_check
|
|
424
|
+
|
|
425
|
+
# Séparer la partie principale des query params
|
|
426
|
+
if '?' in perm:
|
|
427
|
+
from urllib.parse import parse_qs
|
|
428
|
+
|
|
429
|
+
main_part, query_string = perm.split('?', 1)
|
|
430
|
+
# Parser les query params
|
|
431
|
+
parsed_qs = parse_qs(query_string)
|
|
432
|
+
# Convertir en dict simple (prendre la première valeur de chaque liste)
|
|
433
|
+
query_context = {k: v[0] if len(v) == 1 else v for k, v in parsed_qs.items()}
|
|
434
|
+
# Convertir les valeurs numériques
|
|
435
|
+
for k, v in query_context.items():
|
|
436
|
+
if isinstance(v, str) and v.isdigit():
|
|
437
|
+
query_context[k] = int(v)
|
|
438
|
+
else:
|
|
439
|
+
main_part = perm
|
|
440
|
+
query_context = {}
|
|
441
|
+
|
|
442
|
+
# Parser la partie principale
|
|
443
|
+
parts = main_part.split(':')
|
|
444
|
+
|
|
445
|
+
if len(parts) < 2:
|
|
446
|
+
raise ValueError(
|
|
447
|
+
f"Format de permission invalide: '{perm}'. "
|
|
448
|
+
"Format attendu: '<scope>:<actions>' ou '<scope>:<actions>:<group>' "
|
|
449
|
+
"ou '<scope>:<actions>:<group>?key=value&key2=value2'"
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
scope = parts[0]
|
|
453
|
+
actions_str = parts[1]
|
|
454
|
+
group = parts[2] if len(parts) > 2 else None
|
|
455
|
+
|
|
456
|
+
# Convertir la chaîne d'actions en liste
|
|
457
|
+
# 'rwd' -> ['r', 'w', 'd']
|
|
458
|
+
required = list(actions_str)
|
|
459
|
+
|
|
460
|
+
# Fusionner les contextes (kwargs ont priorité sur query params)
|
|
461
|
+
final_context = {**query_context, **context}
|
|
462
|
+
|
|
463
|
+
return cache_check(user, scope, required, group=group, **final_context)
|
|
464
|
+
|
|
465
|
+
def load_preset(*, force: bool = False) -> dict[str, int]:
|
|
466
|
+
"""
|
|
467
|
+
Charge un preset de permissions depuis les settings Django.
|
|
468
|
+
Utilisé par la commande de management load_permission_preset.
|
|
469
|
+
|
|
470
|
+
Par sécurité, si des rôles existent déjà en base, la fonction lève une exception
|
|
471
|
+
sauf si force=True est passé explicitement.
|
|
472
|
+
|
|
473
|
+
Args:
|
|
474
|
+
force: Si True, permet de charger le preset même si des rôles existent déjà.
|
|
475
|
+
Par défaut False pour éviter l'écrasement accidentel.
|
|
476
|
+
|
|
477
|
+
Le preset doit être défini dans settings.PERMISSION_PRESET avec la structure suivante:
|
|
478
|
+
|
|
479
|
+
PERMISSION_PRESET = {
|
|
480
|
+
"roles": [
|
|
481
|
+
{
|
|
482
|
+
"name": "Accountant",
|
|
483
|
+
"slug": "accountant"
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
"name": "Admin",
|
|
487
|
+
"slug": "admin"
|
|
488
|
+
}
|
|
489
|
+
],
|
|
490
|
+
"scopes": ['users', 'articles', 'comments'],
|
|
491
|
+
"group": [
|
|
492
|
+
{
|
|
493
|
+
"name": "Admins",
|
|
494
|
+
"slug": "admins",
|
|
495
|
+
"roles": ["admin"]
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
"name": "Accountants",
|
|
499
|
+
"slug": "accountants",
|
|
500
|
+
"roles": ["accountant"]
|
|
501
|
+
}
|
|
502
|
+
],
|
|
503
|
+
"role_grants": [
|
|
504
|
+
{
|
|
505
|
+
"role": "admin",
|
|
506
|
+
"scope": "users",
|
|
507
|
+
"actions": ["r", "w", "d"],
|
|
508
|
+
"context": {}
|
|
509
|
+
# "group": "slug" # Optionnel: si absent ou None, RoleGrant générique
|
|
510
|
+
},
|
|
511
|
+
{
|
|
512
|
+
"role": "accountant",
|
|
513
|
+
"scope": "users",
|
|
514
|
+
"actions": ["r"],
|
|
515
|
+
"context": {},
|
|
516
|
+
"group": "accountants" # RoleGrant spécifique au groupe accountants
|
|
517
|
+
}
|
|
518
|
+
]
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
Returns:
|
|
522
|
+
Dictionnaire avec les statistiques de création:
|
|
523
|
+
{
|
|
524
|
+
"roles": nombre de rôles créés,
|
|
525
|
+
"groups": nombre de groupes créés,
|
|
526
|
+
"role_grants": nombre de role_grants créés
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
Raises:
|
|
530
|
+
AttributeError: Si PERMISSION_PRESET n'est pas défini dans settings
|
|
531
|
+
KeyError: Si une clé requise est manquante dans le preset
|
|
532
|
+
PermissionError: Si des rôles existent déjà et force=False
|
|
533
|
+
"""
|
|
534
|
+
from django.conf import settings
|
|
535
|
+
|
|
536
|
+
# Récupérer le preset depuis les settings
|
|
537
|
+
preset = getattr(settings, 'PERMISSION_PRESET', None)
|
|
538
|
+
if preset is None:
|
|
539
|
+
raise AttributeError(
|
|
540
|
+
"PERMISSION_PRESET n'est pas défini dans les settings Django"
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
# Sécurité : vérifier si des rôles existent déjà
|
|
544
|
+
existing_roles_count = Role.objects.count()
|
|
545
|
+
if existing_roles_count > 0 and not force:
|
|
546
|
+
raise PermissionError(
|
|
547
|
+
f"Des rôles existent déjà en base de données ({existing_roles_count} rôle(s)). "
|
|
548
|
+
"Pour charger le preset malgré tout, utilisez l'option --force. "
|
|
549
|
+
"Attention : cela peut créer des doublons ou modifier les permissions existantes."
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
stats = {
|
|
553
|
+
"roles": 0,
|
|
554
|
+
"groups": 0,
|
|
555
|
+
"role_grants": 0
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
# Cache local pour éviter les requêtes répétées
|
|
559
|
+
roles_cache: dict[str, Role] = {}
|
|
560
|
+
groups_cache: dict[str, Group] = {}
|
|
561
|
+
|
|
562
|
+
# Créer les rôles et peupler le cache
|
|
563
|
+
roles_data = preset.get('roles', [])
|
|
564
|
+
for role_data in roles_data:
|
|
565
|
+
role, created = Role.objects.get_or_create(
|
|
566
|
+
slug=role_data['slug'],
|
|
567
|
+
defaults={'name': role_data['name']}
|
|
568
|
+
)
|
|
569
|
+
roles_cache[role.slug] = role
|
|
570
|
+
if created:
|
|
571
|
+
stats['roles'] += 1
|
|
572
|
+
|
|
573
|
+
# Créer les groupes et peupler le cache
|
|
574
|
+
groups_data = preset.get('group', [])
|
|
575
|
+
for group_data in groups_data:
|
|
576
|
+
group, created = Group.objects.get_or_create(
|
|
577
|
+
slug=group_data['slug'],
|
|
578
|
+
defaults={'name': group_data['name']}
|
|
579
|
+
)
|
|
580
|
+
groups_cache[group.slug] = group
|
|
581
|
+
if created:
|
|
582
|
+
stats['groups'] += 1
|
|
583
|
+
|
|
584
|
+
# Associer les rôles au groupe en utilisant le cache
|
|
585
|
+
role_slugs = group_data.get('roles', [])
|
|
586
|
+
for role_slug in role_slugs:
|
|
587
|
+
# Utiliser le cache au lieu de requêter la base
|
|
588
|
+
role = roles_cache.get(role_slug)
|
|
589
|
+
if role is None:
|
|
590
|
+
raise ValueError(
|
|
591
|
+
f"Le rôle '{role_slug}' n'existe pas pour le groupe '{group.slug}'"
|
|
592
|
+
)
|
|
593
|
+
group.roles.add(role)
|
|
594
|
+
|
|
595
|
+
# Créer les role_grants en utilisant le cache
|
|
596
|
+
role_grants_data = preset.get('role_grants', [])
|
|
597
|
+
for rg_data in role_grants_data:
|
|
598
|
+
# Utiliser le cache au lieu de requêter la base
|
|
599
|
+
role = roles_cache.get(rg_data['role'])
|
|
600
|
+
if role is None:
|
|
601
|
+
raise ValueError(
|
|
602
|
+
f"Le rôle '{rg_data['role']}' n'existe pas pour le role_grant"
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
# Gérer le groupe optionnel
|
|
606
|
+
group_obj = None
|
|
607
|
+
group_slug = rg_data.get('group')
|
|
608
|
+
if group_slug:
|
|
609
|
+
group_obj = groups_cache.get(group_slug)
|
|
610
|
+
if group_obj is None:
|
|
611
|
+
raise ValueError(
|
|
612
|
+
f"Le groupe '{group_slug}' n'existe pas pour le role_grant"
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
# Utiliser get_or_create avec la contrainte complète (role, scope, group)
|
|
616
|
+
role_grant, created = RoleGrant.objects.get_or_create(
|
|
617
|
+
role=role,
|
|
618
|
+
scope=rg_data['scope'],
|
|
619
|
+
group=group_obj,
|
|
620
|
+
defaults={
|
|
621
|
+
'actions': rg_data.get('actions', []),
|
|
622
|
+
'context': rg_data.get('context', {})
|
|
623
|
+
}
|
|
624
|
+
)
|
|
625
|
+
if created:
|
|
626
|
+
stats['role_grants'] += 1
|
|
627
|
+
|
|
628
|
+
return stats
|