oxutils 0.1.7__py3-none-any.whl → 0.1.10__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.
Files changed (55) hide show
  1. oxutils/__init__.py +2 -1
  2. oxutils/constants.py +4 -0
  3. oxutils/jwt/auth.py +150 -1
  4. oxutils/jwt/models.py +32 -1
  5. oxutils/jwt/utils.py +45 -0
  6. oxutils/logger/receivers.py +10 -6
  7. oxutils/models/base.py +102 -0
  8. oxutils/models/fields.py +79 -0
  9. oxutils/oxiliere/apps.py +6 -1
  10. oxutils/oxiliere/authorization.py +45 -0
  11. oxutils/oxiliere/caches.py +8 -7
  12. oxutils/oxiliere/checks.py +31 -0
  13. oxutils/oxiliere/constants.py +3 -0
  14. oxutils/oxiliere/context.py +18 -0
  15. oxutils/oxiliere/exceptions.py +16 -0
  16. oxutils/oxiliere/management/commands/grant_tenant_owners.py +19 -0
  17. oxutils/oxiliere/management/commands/init_oxiliere_system.py +20 -8
  18. oxutils/oxiliere/middleware.py +29 -13
  19. oxutils/oxiliere/models.py +130 -19
  20. oxutils/oxiliere/permissions.py +6 -5
  21. oxutils/oxiliere/schemas.py +13 -4
  22. oxutils/oxiliere/signals.py +5 -0
  23. oxutils/oxiliere/utils.py +18 -0
  24. oxutils/pagination/__init__.py +0 -0
  25. oxutils/pagination/cursor.py +367 -0
  26. oxutils/permissions/__init__.py +0 -0
  27. oxutils/permissions/actions.py +57 -0
  28. oxutils/permissions/admin.py +3 -0
  29. oxutils/permissions/apps.py +10 -0
  30. oxutils/permissions/caches.py +19 -0
  31. oxutils/permissions/checks.py +188 -0
  32. oxutils/permissions/constants.py +0 -0
  33. oxutils/permissions/controllers.py +344 -0
  34. oxutils/permissions/exceptions.py +60 -0
  35. oxutils/permissions/management/__init__.py +0 -0
  36. oxutils/permissions/management/commands/__init__.py +0 -0
  37. oxutils/permissions/management/commands/load_permission_preset.py +112 -0
  38. oxutils/permissions/migrations/0001_initial.py +112 -0
  39. oxutils/permissions/migrations/0002_alter_grant_role.py +19 -0
  40. oxutils/permissions/migrations/0003_alter_grant_options_alter_group_options_and_more.py +33 -0
  41. oxutils/permissions/migrations/__init__.py +0 -0
  42. oxutils/permissions/models.py +171 -0
  43. oxutils/permissions/perms.py +95 -0
  44. oxutils/permissions/queryset.py +92 -0
  45. oxutils/permissions/schemas.py +276 -0
  46. oxutils/permissions/services.py +663 -0
  47. oxutils/permissions/tests.py +3 -0
  48. oxutils/permissions/utils.py +628 -0
  49. oxutils/settings.py +1 -0
  50. oxutils/users/migrations/0002_alter_user_first_name_alter_user_last_name.py +23 -0
  51. oxutils/users/models.py +2 -0
  52. oxutils/utils.py +25 -0
  53. {oxutils-0.1.7.dist-info → oxutils-0.1.10.dist-info}/METADATA +1 -1
  54. {oxutils-0.1.7.dist-info → oxutils-0.1.10.dist-info}/RECORD +55 -19
  55. {oxutils-0.1.7.dist-info → oxutils-0.1.10.dist-info}/WHEEL +0 -0
@@ -0,0 +1,171 @@
1
+ from django.conf import settings
2
+ from django.db import models
3
+ from django.contrib.postgres.fields import ArrayField
4
+ from django.contrib.postgres.indexes import GinIndex
5
+ from oxutils.models import TimestampMixin
6
+ from .actions import expand_actions
7
+
8
+
9
+
10
+
11
+ class Role(TimestampMixin):
12
+ """
13
+ A role.
14
+ """
15
+ slug = models.SlugField(unique=True, primary_key=True)
16
+ name = models.CharField(max_length=100)
17
+
18
+ def __str__(self):
19
+ return self.slug
20
+
21
+ class Meta:
22
+ indexes = [
23
+ models.Index(fields=["slug"]),
24
+ ]
25
+ ordering = ["slug"]
26
+
27
+
28
+ class Group(TimestampMixin):
29
+ """
30
+ A group of roles. for UI Template purposes.
31
+ """
32
+ slug = models.SlugField(unique=True, primary_key=True)
33
+ name = models.CharField(max_length=100)
34
+ roles = models.ManyToManyField(Role, related_name="groups")
35
+
36
+ def __str__(self):
37
+ return self.slug
38
+
39
+ class Meta:
40
+ indexes = [
41
+ models.Index(fields=["slug"]),
42
+ ]
43
+ ordering = ["slug"]
44
+
45
+
46
+ class UserGroup(TimestampMixin):
47
+ """
48
+ A user group that links users to groups.
49
+ """
50
+ user = models.ForeignKey(
51
+ settings.AUTH_USER_MODEL,
52
+ on_delete=models.CASCADE,
53
+ related_name="user_groups",
54
+ )
55
+ group = models.ForeignKey(
56
+ Group,
57
+ on_delete=models.CASCADE,
58
+ related_name="user_groups",
59
+ )
60
+
61
+ class Meta:
62
+ constraints = [
63
+ models.UniqueConstraint(fields=['user', 'group'], name='unique_user_group')
64
+ ]
65
+ indexes = [
66
+ models.Index(fields=['user', 'group']),
67
+ ]
68
+
69
+
70
+ class RoleGrant(models.Model):
71
+ """
72
+ A grant template of permissions to a role.
73
+ Peut être lié à un groupe spécifique pour des comportements distincts.
74
+ """
75
+ role = models.ForeignKey(Role, on_delete=models.CASCADE, related_name="grants")
76
+ group = models.ForeignKey(
77
+ Group,
78
+ null=True,
79
+ blank=True,
80
+ on_delete=models.CASCADE,
81
+ related_name="role_grants",
82
+ help_text="Groupe optionnel pour des comportements spécifiques"
83
+ )
84
+
85
+ scope = models.CharField(max_length=100)
86
+ actions = ArrayField(models.CharField(max_length=5))
87
+ context = models.JSONField(default=dict, blank=True)
88
+
89
+ def clean(self):
90
+ self.actions = expand_actions(self.actions)
91
+
92
+ class Meta:
93
+ constraints = [
94
+ models.UniqueConstraint(
95
+ fields=["role", "scope", "group"], name="unique_role_scope_group"
96
+ )
97
+ ]
98
+ indexes = [
99
+ models.Index(fields=["role"]),
100
+ models.Index(fields=["group"]),
101
+ models.Index(fields=["role", "group"]),
102
+ ]
103
+ ordering = ["role__slug", "group__slug"]
104
+
105
+ def __str__(self):
106
+ group_str = f"[{self.group.slug}]" if self.group else ""
107
+ return f"{self.role}:{self.scope}{group_str}:{self.actions}"
108
+
109
+
110
+ def save(self, *args, **kwargs):
111
+ self.clean()
112
+ super().save(*args, **kwargs)
113
+
114
+
115
+ class Grant(TimestampMixin):
116
+ """
117
+ A grant of permissions to a user.
118
+ """
119
+ user = models.ForeignKey(
120
+ settings.AUTH_USER_MODEL,
121
+ on_delete=models.CASCADE,
122
+ related_name="grants",
123
+ )
124
+
125
+ # traçabilité
126
+ role = models.ForeignKey(
127
+ Role,
128
+ null=True,
129
+ blank=True,
130
+ related_name="user_grants",
131
+ on_delete=models.SET_NULL,
132
+ )
133
+
134
+ # Lien avec UserGroup pour tracer l'origine du grant
135
+ user_group = models.ForeignKey(
136
+ 'UserGroup',
137
+ null=True,
138
+ blank=True,
139
+ related_name="grants",
140
+ on_delete=models.SET_NULL,
141
+ )
142
+
143
+ created_by = models.ForeignKey(
144
+ settings.AUTH_USER_MODEL,
145
+ null=True,
146
+ blank=True,
147
+ related_name="created_grants",
148
+ on_delete=models.SET_NULL,
149
+ )
150
+
151
+ scope = models.CharField(max_length=100)
152
+ actions = ArrayField(models.CharField(max_length=5))
153
+ context = models.JSONField(default=dict, blank=True)
154
+
155
+
156
+ class Meta:
157
+ constraints = [
158
+ models.UniqueConstraint(
159
+ fields=["user", "scope", "role", "user_group"], name="unique_user_scope_role"
160
+ )
161
+ ]
162
+ indexes = [
163
+ models.Index(fields=["user", "scope"]),
164
+ models.Index(fields=["user_group"]),
165
+ GinIndex(fields=["actions"]),
166
+ GinIndex(fields=["context"]),
167
+ ]
168
+ ordering = ["scope"]
169
+
170
+ def __str__(self):
171
+ return f"{self.user} {self.scope} {self.actions}"
@@ -0,0 +1,95 @@
1
+ from typing import Optional
2
+ from django.conf import settings
3
+ from django.core.exceptions import ImproperlyConfigured
4
+ from django.http import HttpRequest
5
+ from ninja_extra.permissions import BasePermission
6
+ from ninja_extra.controllers import ControllerBase
7
+
8
+ from oxutils.permissions.utils import str_check
9
+
10
+
11
+
12
+ class ScopePermission(BasePermission):
13
+ """
14
+ Permission class for checking user permissions using the string format.
15
+
16
+ Format: "<scope>:<actions>:<group>?key=value"
17
+
18
+ Example:
19
+ @api_controller('/articles', permissions=[ScopePermission('articles:w:staff')])
20
+ class ArticleController:
21
+ pass
22
+ """
23
+
24
+ def __init__(self, perm: str, ctx: Optional[dict] = None):
25
+ """
26
+ Initialize the permission checker.
27
+
28
+ Args:
29
+ perm: Permission string in format "<scope>:<actions>:<group>?context"
30
+ """
31
+ self.perm = perm
32
+ self.ctx = ctx if ctx else dict()
33
+
34
+ def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
35
+ """
36
+ Check if the user has the required permission.
37
+
38
+ Args:
39
+ request: HTTP request object
40
+ controller: Controller instance
41
+
42
+ Returns:
43
+ True if user has permission, False otherwise
44
+ """
45
+ return str_check(request.user, self.perm, **self.ctx)
46
+
47
+
48
+ def access_manager(actions: str):
49
+ """
50
+ Factory function for creating ScopePermission instances for access manager.
51
+
52
+ Builds a permission string from settings:
53
+ - ACCESS_MANAGER_SCOPE: The scope to check
54
+ - ACCESS_MANAGER_GROUP: Optional group filter
55
+ - ACCESS_MANAGER_CONTEXT: Optional context dict converted to query params
56
+
57
+ Args:
58
+ actions: Actions required (e.g., 'r', 'rw', 'rwd')
59
+
60
+ Returns:
61
+ ScopePermission instance configured with access manager settings
62
+
63
+ Raises:
64
+ ImproperlyConfigured: If required settings are missing
65
+
66
+ Example:
67
+ @api_controller('/access', permissions=[access_manager('w')])
68
+ class AccessController:
69
+ pass
70
+ """
71
+ # Validate required settings
72
+ if not hasattr(settings, 'ACCESS_MANAGER_SCOPE'):
73
+ raise ImproperlyConfigured(
74
+ 'ACCESS_MANAGER_SCOPE is not defined. '
75
+ 'Add ACCESS_MANAGER_SCOPE = "access" to your settings.'
76
+ )
77
+
78
+ # Build base permission string: scope:actions
79
+ perm = f"{settings.ACCESS_MANAGER_SCOPE}:{actions}"
80
+
81
+ # Add group if defined and not None
82
+ if hasattr(settings, 'ACCESS_MANAGER_GROUP') and settings.ACCESS_MANAGER_GROUP is not None:
83
+ perm += f":{settings.ACCESS_MANAGER_GROUP}"
84
+
85
+ # Get context if defined and not empty
86
+ context = {}
87
+ if hasattr(settings, 'ACCESS_MANAGER_CONTEXT') and settings.ACCESS_MANAGER_CONTEXT:
88
+ context = settings.ACCESS_MANAGER_CONTEXT
89
+ if not isinstance(context, dict):
90
+ raise ImproperlyConfigured(
91
+ 'ACCESS_MANAGER_CONTEXT must be a dictionary. '
92
+ f'Got {type(context).__name__} instead.'
93
+ )
94
+
95
+ return ScopePermission(perm, context)
@@ -0,0 +1,92 @@
1
+ from typing import Any
2
+ from django.db import models
3
+ from django.db.models import Q
4
+ from django.contrib.auth.models import AbstractBaseUser
5
+
6
+ from .models import Grant
7
+
8
+
9
+ class PermissionQuerySet(models.QuerySet):
10
+ """
11
+ QuerySet personnalisé pour filtrer des objets selon les permissions d'un utilisateur.
12
+ Permet de filtrer des querysets en fonction des grants de permissions.
13
+ """
14
+
15
+ def allowed_for(
16
+ self,
17
+ user: AbstractBaseUser,
18
+ scope: str,
19
+ required_actions: list[str],
20
+ **context: Any
21
+ ) -> "PermissionQuerySet":
22
+ """
23
+ Filtre les objets si l'utilisateur a les permissions requises.
24
+ Vérifie l'existence d'un grant valide avant de retourner le queryset.
25
+
26
+ Args:
27
+ user: L'utilisateur dont on vérifie les permissions
28
+ scope: Le scope à vérifier (ex: 'articles', 'users')
29
+ required_actions: Liste des actions requises (ex: ['r'], ['w', 'r'])
30
+ **context: Contexte additionnel pour filtrer (ex: tenant_id=123)
31
+
32
+ Returns:
33
+ QuerySet complet si autorisé, QuerySet vide sinon
34
+
35
+ Example:
36
+ >>> Article.objects.allowed_for(user, 'articles', ['r'])
37
+ >>> Article.objects.allowed_for(user, 'articles', ['w'], tenant_id=123)
38
+ """
39
+ # Construire le filtre pour vérifier l'existence d'un grant
40
+ grant_filter = Q(
41
+ user__pk=user.pk,
42
+ scope=scope,
43
+ actions__contains=list(required_actions),
44
+ )
45
+
46
+ # Ajouter les filtres de contexte si fournis
47
+ if context:
48
+ grant_filter &= Q(context__contains=context)
49
+
50
+ # Si un grant existe, retourner le queryset complet, sinon vide
51
+ if Grant.objects.filter(grant_filter).exists():
52
+ return self
53
+ return self.none()
54
+
55
+ def denied_for(
56
+ self,
57
+ user: AbstractBaseUser,
58
+ scope: str,
59
+ required_actions: list[str],
60
+ **context: Any
61
+ ) -> "PermissionQuerySet":
62
+ """
63
+ Filtre les objets si l'utilisateur N'A PAS les permissions requises.
64
+ Inverse de allowed_for.
65
+
66
+ Args:
67
+ user: L'utilisateur dont on vérifie les permissions
68
+ scope: Le scope à vérifier
69
+ required_actions: Liste des actions requises
70
+ **context: Contexte additionnel pour filtrer
71
+
72
+ Returns:
73
+ QuerySet complet si NON autorisé, QuerySet vide si autorisé
74
+
75
+ Example:
76
+ >>> Article.objects.denied_for(user, 'articles', ['w'])
77
+ """
78
+ # Construire le filtre pour vérifier l'existence d'un grant
79
+ grant_filter = Q(
80
+ user__pk=user.pk,
81
+ scope=scope,
82
+ actions__contains=list(required_actions),
83
+ )
84
+
85
+ # Ajouter les filtres de contexte si fournis
86
+ if context:
87
+ grant_filter &= Q(context__contains=context)
88
+
89
+ # Si un grant existe, retourner vide, sinon le queryset complet
90
+ if Grant.objects.filter(grant_filter).exists():
91
+ return self.none()
92
+ return self
@@ -0,0 +1,276 @@
1
+ from typing import Any, Optional
2
+ from datetime import datetime
3
+ from ninja import Schema
4
+ from pydantic import field_validator
5
+
6
+ from .actions import ACTIONS
7
+
8
+
9
+ def validate_actions_list(actions: list[str]) -> list[str]:
10
+ """
11
+ Valide qu'une liste d'actions contient uniquement des actions valides.
12
+
13
+ Args:
14
+ actions: Liste des actions à valider
15
+
16
+ Returns:
17
+ La liste d'actions si valide
18
+
19
+ Raises:
20
+ ValueError: Si des actions invalides sont présentes
21
+ """
22
+ invalid_actions = [a for a in actions if a not in ACTIONS]
23
+ if invalid_actions:
24
+ raise ValueError(
25
+ f"Actions invalides: {invalid_actions}. "
26
+ f"Actions valides: {ACTIONS}"
27
+ )
28
+ return actions
29
+
30
+
31
+ class RoleSchema(Schema):
32
+ """
33
+ Schéma pour un rôle.
34
+ """
35
+ slug: str
36
+ name: str
37
+ created_at: datetime
38
+ updated_at: datetime
39
+
40
+ class Config:
41
+ from_attributes = True
42
+
43
+
44
+ class RoleCreateSchema(Schema):
45
+ """
46
+ Schéma pour la création d'un rôle.
47
+ """
48
+ slug: str
49
+ name: str
50
+
51
+
52
+ class RoleUpdateSchema(Schema):
53
+ """
54
+ Schéma pour la mise à jour d'un rôle.
55
+ """
56
+ name: Optional[str] = None
57
+
58
+
59
+ class GroupSchema(Schema):
60
+ """
61
+ Schéma pour un groupe.
62
+ """
63
+ slug: str
64
+ name: str
65
+ roles: list[RoleSchema] = []
66
+ created_at: datetime
67
+ updated_at: datetime
68
+
69
+ class Config:
70
+ from_attributes = True
71
+
72
+
73
+ class GroupCreateSchema(Schema):
74
+ """
75
+ Schéma pour la création d'un groupe.
76
+ """
77
+ slug: str
78
+ name: str
79
+ roles: list[str] = []
80
+
81
+
82
+ class GroupUpdateSchema(Schema):
83
+ """
84
+ Schéma pour la mise à jour d'un groupe.
85
+ """
86
+ name: Optional[str] = None
87
+ roles: Optional[list[str]] = None
88
+
89
+
90
+ class RoleGrantSchema(Schema):
91
+ """
92
+ Schéma pour un role grant.
93
+ """
94
+ id: int
95
+ role: RoleSchema
96
+ scope: str
97
+ actions: list[str]
98
+ context: dict[str, Any] = {}
99
+
100
+ class Config:
101
+ from_attributes = True
102
+
103
+
104
+ class RoleGrantCreateSchema(Schema):
105
+ """
106
+ Schéma pour la création d'un role grant.
107
+ """
108
+ role: str
109
+ scope: str
110
+ actions: list[str]
111
+ context: dict[str, Any] = {}
112
+
113
+ @field_validator('actions')
114
+ @classmethod
115
+ def validate_actions(cls, v: list[str]) -> list[str]:
116
+ """Valide que toutes les actions sont valides."""
117
+ return validate_actions_list(v)
118
+
119
+
120
+ class RoleGrantUpdateSchema(Schema):
121
+ """
122
+ Schéma pour la mise à jour d'un role grant.
123
+ """
124
+ actions: Optional[list[str]] = None
125
+ context: Optional[dict[str, Any]] = None
126
+
127
+ @field_validator('actions')
128
+ @classmethod
129
+ def validate_actions(cls, v: Optional[list[str]]) -> Optional[list[str]]:
130
+ """Valide que toutes les actions sont valides."""
131
+ if v is not None:
132
+ return validate_actions_list(v)
133
+ return v
134
+
135
+
136
+ class GrantSchema(Schema):
137
+ """
138
+ Schéma pour un grant utilisateur.
139
+ """
140
+ id: int
141
+ user_id: int
142
+ role: Optional[RoleSchema] = None
143
+ scope: str
144
+ actions: list[str]
145
+ context: dict[str, Any] = {}
146
+ created_at: datetime
147
+ updated_at: datetime
148
+
149
+ class Config:
150
+ from_attributes = True
151
+
152
+
153
+ class GrantCreateSchema(Schema):
154
+ """
155
+ Schéma pour la création d'un grant utilisateur.
156
+ """
157
+ user_id: int
158
+ scope: str
159
+ actions: list[str]
160
+ context: dict[str, Any] = {}
161
+ role: Optional[str] = None
162
+
163
+ @field_validator('actions')
164
+ @classmethod
165
+ def validate_actions(cls, v: list[str]) -> list[str]:
166
+ """Valide que toutes les actions sont valides."""
167
+ return validate_actions_list(v)
168
+
169
+
170
+ class GrantUpdateSchema(Schema):
171
+ """
172
+ Schéma pour la mise à jour d'un grant utilisateur.
173
+ """
174
+ actions: Optional[list[str]] = None
175
+ context: Optional[dict[str, Any]] = None
176
+ role: Optional[str] = None
177
+
178
+ @field_validator('actions')
179
+ @classmethod
180
+ def validate_actions(cls, v: Optional[list[str]]) -> Optional[list[str]]:
181
+ """Valide que toutes les actions sont valides."""
182
+ if v is not None:
183
+ return validate_actions_list(v)
184
+ return v
185
+
186
+
187
+ class PermissionCheckSchema(Schema):
188
+ """
189
+ Schéma pour une requête de vérification de permissions.
190
+ """
191
+ user_id: int
192
+ scope: str
193
+ required_actions: list[str]
194
+ context: dict[str, Any] = {}
195
+
196
+ @field_validator('required_actions')
197
+ @classmethod
198
+ def validate_actions(cls, v: list[str]) -> list[str]:
199
+ """Valide que toutes les actions sont valides."""
200
+ return validate_actions_list(v)
201
+
202
+
203
+ class PermissionCheckResponseSchema(Schema):
204
+ """
205
+ Schéma pour la réponse d'une vérification de permissions.
206
+ """
207
+ allowed: bool
208
+ user_id: int
209
+ scope: str
210
+ required_actions: list[str]
211
+
212
+
213
+ class AssignRoleSchema(Schema):
214
+ """
215
+ Schéma pour assigner un rôle à un utilisateur.
216
+ """
217
+ user_id: int
218
+ role: str
219
+ by_user_id: Optional[int] = None
220
+
221
+
222
+ class RevokeRoleSchema(Schema):
223
+ """
224
+ Schéma pour révoquer un rôle d'un utilisateur.
225
+ """
226
+ user_id: int
227
+ role: str
228
+
229
+
230
+ class AssignGroupSchema(Schema):
231
+ """
232
+ Schéma pour assigner un groupe à un utilisateur.
233
+ """
234
+ user_id: int
235
+ group: str
236
+
237
+
238
+ class RevokeGroupSchema(Schema):
239
+ """
240
+ Schéma pour révoquer un groupe d'un utilisateur.
241
+ """
242
+ user_id: int
243
+ group: str
244
+
245
+
246
+ class OverrideGrantSchema(Schema):
247
+ """
248
+ Schéma pour modifier un grant en retirant des actions.
249
+ """
250
+ user_id: int
251
+ scope: str
252
+ remove_actions: list[str]
253
+
254
+ @field_validator('remove_actions')
255
+ @classmethod
256
+ def validate_actions(cls, v: list[str]) -> list[str]:
257
+ """Valide que toutes les actions sont valides."""
258
+ return validate_actions_list(v)
259
+
260
+
261
+ class GroupSyncResponseSchema(Schema):
262
+ """
263
+ Schéma pour la réponse de la synchronisation d'un groupe.
264
+ """
265
+ users_synced: int
266
+ grants_updated: int
267
+
268
+
269
+ class PresetLoadResponseSchema(Schema):
270
+ """
271
+ Schéma pour la réponse du chargement d'un preset.
272
+ """
273
+ roles_created: int
274
+ groups_created: int
275
+ role_grants_created: int
276
+ message: str = "Preset chargé avec succès"