oxutils 0.1.6__py3-none-any.whl → 0.1.14__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 +33 -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 +201 -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 +784 -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/migrations/0003_user_photo.py +18 -0
- oxutils/users/models.py +3 -0
- oxutils/utils.py +25 -0
- {oxutils-0.1.6.dist-info → oxutils-0.1.14.dist-info}/METADATA +14 -11
- oxutils-0.1.14.dist-info/RECORD +123 -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.14.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
3
|
+
from django.db import transaction
|
|
4
|
+
|
|
5
|
+
from oxutils.permissions.utils import load_preset
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Command(BaseCommand):
|
|
9
|
+
"""
|
|
10
|
+
Commande de management Django pour charger un preset de permissions.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
python manage.py load_permission_preset
|
|
14
|
+
python manage.py load_permission_preset --dry-run
|
|
15
|
+
python manage.py load_permission_preset --force
|
|
16
|
+
python manage.py load_permission_preset --dry-run --force
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
help = "Charge un preset de permissions depuis settings.PERMISSION_PRESET"
|
|
20
|
+
|
|
21
|
+
def add_arguments(self, parser) -> None:
|
|
22
|
+
"""
|
|
23
|
+
Ajoute les arguments de la commande.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
parser: ArgumentParser de Django
|
|
27
|
+
"""
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
'--dry-run',
|
|
30
|
+
action='store_true',
|
|
31
|
+
help='Simule le chargement sans créer les objets en base de données',
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
'--force',
|
|
35
|
+
action='store_true',
|
|
36
|
+
help='Force le chargement même si des rôles existent déjà en base',
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@transaction.atomic
|
|
40
|
+
def handle(self, *args: Any, **options: Any) -> None:
|
|
41
|
+
"""
|
|
42
|
+
Exécute la commande de chargement du preset.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
*args: Arguments positionnels
|
|
46
|
+
**options: Options de la commande
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
CommandError: Si le preset n'est pas défini ou est invalide
|
|
50
|
+
"""
|
|
51
|
+
dry_run = options.get('dry_run', False)
|
|
52
|
+
force = options.get('force', False)
|
|
53
|
+
|
|
54
|
+
if dry_run:
|
|
55
|
+
self.stdout.write(
|
|
56
|
+
self.style.WARNING('Mode DRY-RUN activé - Aucune modification ne sera effectuée')
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if force:
|
|
60
|
+
self.stdout.write(
|
|
61
|
+
self.style.WARNING('Mode FORCE activé - Les rôles existants seront ignorés')
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
# Charger le preset
|
|
66
|
+
self.stdout.write('Chargement du preset de permissions...')
|
|
67
|
+
|
|
68
|
+
if dry_run:
|
|
69
|
+
# En mode dry-run, on utilise un savepoint pour rollback
|
|
70
|
+
sid = transaction.savepoint()
|
|
71
|
+
|
|
72
|
+
stats = load_preset(force=force)
|
|
73
|
+
|
|
74
|
+
if dry_run:
|
|
75
|
+
# Rollback en mode dry-run
|
|
76
|
+
transaction.savepoint_rollback(sid)
|
|
77
|
+
|
|
78
|
+
# Afficher les statistiques
|
|
79
|
+
self.stdout.write(
|
|
80
|
+
self.style.SUCCESS('\n✓ Preset chargé avec succès!')
|
|
81
|
+
)
|
|
82
|
+
self.stdout.write(f" • Rôles créés: {stats['roles']}")
|
|
83
|
+
self.stdout.write(f" • Groupes créés: {stats['groups']}")
|
|
84
|
+
self.stdout.write(f" • Role grants créés: {stats['role_grants']}")
|
|
85
|
+
|
|
86
|
+
if dry_run:
|
|
87
|
+
self.stdout.write(
|
|
88
|
+
self.style.WARNING('\nAucune modification effectuée (mode dry-run)')
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
except AttributeError as e:
|
|
92
|
+
raise CommandError(
|
|
93
|
+
f"Erreur de configuration: {str(e)}\n"
|
|
94
|
+
"Assurez-vous que PERMISSION_PRESET est défini dans vos settings Django."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
except PermissionError as e:
|
|
98
|
+
raise CommandError(
|
|
99
|
+
f"{str(e)}\n"
|
|
100
|
+
"Utilisez --force pour forcer le chargement malgré les rôles existants."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
except (KeyError, ValueError) as e:
|
|
104
|
+
raise CommandError(
|
|
105
|
+
f"Erreur dans le preset: {str(e)}\n"
|
|
106
|
+
"Vérifiez la structure de votre PERMISSION_PRESET."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
except Exception as e:
|
|
110
|
+
raise CommandError(
|
|
111
|
+
f"Erreur inattendue lors du chargement du preset: {str(e)}"
|
|
112
|
+
)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Generated by Django 5.2.9 on 2025-12-27 10:49
|
|
2
|
+
|
|
3
|
+
import django.contrib.postgres.fields
|
|
4
|
+
import django.contrib.postgres.indexes
|
|
5
|
+
import django.db.models.deletion
|
|
6
|
+
from django.conf import settings
|
|
7
|
+
from django.db import migrations, models
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Migration(migrations.Migration):
|
|
11
|
+
|
|
12
|
+
initial = True
|
|
13
|
+
|
|
14
|
+
dependencies = [
|
|
15
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
operations = [
|
|
19
|
+
migrations.CreateModel(
|
|
20
|
+
name='Role',
|
|
21
|
+
fields=[
|
|
22
|
+
('created_at', models.DateTimeField(auto_now_add=True, help_text='Date and time when this record was created')),
|
|
23
|
+
('updated_at', models.DateTimeField(auto_now=True, help_text='Date and time when this record was last updated')),
|
|
24
|
+
('slug', models.SlugField(primary_key=True, serialize=False, unique=True)),
|
|
25
|
+
('name', models.CharField(max_length=100)),
|
|
26
|
+
],
|
|
27
|
+
options={
|
|
28
|
+
'indexes': [models.Index(fields=['slug'], name='permissions_slug_ae4163_idx')],
|
|
29
|
+
},
|
|
30
|
+
),
|
|
31
|
+
migrations.CreateModel(
|
|
32
|
+
name='Group',
|
|
33
|
+
fields=[
|
|
34
|
+
('created_at', models.DateTimeField(auto_now_add=True, help_text='Date and time when this record was created')),
|
|
35
|
+
('updated_at', models.DateTimeField(auto_now=True, help_text='Date and time when this record was last updated')),
|
|
36
|
+
('slug', models.SlugField(primary_key=True, serialize=False, unique=True)),
|
|
37
|
+
('name', models.CharField(max_length=100)),
|
|
38
|
+
('roles', models.ManyToManyField(related_name='groups', to='permissions.role')),
|
|
39
|
+
],
|
|
40
|
+
options={
|
|
41
|
+
'abstract': False,
|
|
42
|
+
},
|
|
43
|
+
),
|
|
44
|
+
migrations.CreateModel(
|
|
45
|
+
name='UserGroup',
|
|
46
|
+
fields=[
|
|
47
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
48
|
+
('created_at', models.DateTimeField(auto_now_add=True, help_text='Date and time when this record was created')),
|
|
49
|
+
('updated_at', models.DateTimeField(auto_now=True, help_text='Date and time when this record was last updated')),
|
|
50
|
+
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_groups', to='permissions.group')),
|
|
51
|
+
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_groups', to=settings.AUTH_USER_MODEL)),
|
|
52
|
+
],
|
|
53
|
+
),
|
|
54
|
+
migrations.CreateModel(
|
|
55
|
+
name='Grant',
|
|
56
|
+
fields=[
|
|
57
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
58
|
+
('created_at', models.DateTimeField(auto_now_add=True, help_text='Date and time when this record was created')),
|
|
59
|
+
('updated_at', models.DateTimeField(auto_now=True, help_text='Date and time when this record was last updated')),
|
|
60
|
+
('scope', models.CharField(max_length=100)),
|
|
61
|
+
('actions', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=5), size=None)),
|
|
62
|
+
('context', models.JSONField(blank=True, default=dict)),
|
|
63
|
+
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_grants', to=settings.AUTH_USER_MODEL)),
|
|
64
|
+
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='grants', to=settings.AUTH_USER_MODEL)),
|
|
65
|
+
('role', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='grants', to='permissions.role')),
|
|
66
|
+
('user_group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='grants', to='permissions.usergroup')),
|
|
67
|
+
],
|
|
68
|
+
),
|
|
69
|
+
migrations.CreateModel(
|
|
70
|
+
name='RoleGrant',
|
|
71
|
+
fields=[
|
|
72
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
73
|
+
('scope', models.CharField(max_length=100)),
|
|
74
|
+
('actions', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=5), size=None)),
|
|
75
|
+
('context', models.JSONField(blank=True, default=dict)),
|
|
76
|
+
('group', models.ForeignKey(blank=True, help_text='Groupe optionnel pour des comportements spécifiques', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='role_grants', to='permissions.group')),
|
|
77
|
+
('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='grants', to='permissions.role')),
|
|
78
|
+
],
|
|
79
|
+
options={
|
|
80
|
+
'indexes': [models.Index(fields=['role'], name='permissions_role_id_382ed4_idx'), models.Index(fields=['group'], name='permissions_group_i_465f8d_idx'), models.Index(fields=['role', 'group'], name='permissions_role_id_0818de_idx')],
|
|
81
|
+
'constraints': [models.UniqueConstraint(fields=('role', 'scope', 'group'), name='unique_role_scope_group')],
|
|
82
|
+
},
|
|
83
|
+
),
|
|
84
|
+
migrations.AddIndex(
|
|
85
|
+
model_name='usergroup',
|
|
86
|
+
index=models.Index(fields=['user', 'group'], name='permissions_user_id_f1ff5d_idx'),
|
|
87
|
+
),
|
|
88
|
+
migrations.AddConstraint(
|
|
89
|
+
model_name='usergroup',
|
|
90
|
+
constraint=models.UniqueConstraint(fields=('user', 'group'), name='unique_user_group'),
|
|
91
|
+
),
|
|
92
|
+
migrations.AddIndex(
|
|
93
|
+
model_name='grant',
|
|
94
|
+
index=models.Index(fields=['user', 'scope'], name='permissions_user_id_8a615b_idx'),
|
|
95
|
+
),
|
|
96
|
+
migrations.AddIndex(
|
|
97
|
+
model_name='grant',
|
|
98
|
+
index=models.Index(fields=['user_group'], name='permissions_user_gr_ec61ff_idx'),
|
|
99
|
+
),
|
|
100
|
+
migrations.AddIndex(
|
|
101
|
+
model_name='grant',
|
|
102
|
+
index=django.contrib.postgres.indexes.GinIndex(fields=['actions'], name='permissions_actions_541150_gin'),
|
|
103
|
+
),
|
|
104
|
+
migrations.AddIndex(
|
|
105
|
+
model_name='grant',
|
|
106
|
+
index=django.contrib.postgres.indexes.GinIndex(fields=['context'], name='permissions_context_7b1c0e_gin'),
|
|
107
|
+
),
|
|
108
|
+
migrations.AddConstraint(
|
|
109
|
+
model_name='grant',
|
|
110
|
+
constraint=models.UniqueConstraint(fields=('user', 'scope', 'role', 'user_group'), name='unique_user_scope_role'),
|
|
111
|
+
),
|
|
112
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Generated by Django 5.2.9 on 2025-12-29 09:04
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
|
|
9
|
+
dependencies = [
|
|
10
|
+
('permissions', '0001_initial'),
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
migrations.AlterField(
|
|
15
|
+
model_name='grant',
|
|
16
|
+
name='role',
|
|
17
|
+
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='user_grants', to='permissions.role'),
|
|
18
|
+
),
|
|
19
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Generated by Django 5.2.9 on 2025-12-29 13:28
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
('permissions', '0002_alter_grant_role'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AlterModelOptions(
|
|
14
|
+
name='grant',
|
|
15
|
+
options={'ordering': ['scope']},
|
|
16
|
+
),
|
|
17
|
+
migrations.AlterModelOptions(
|
|
18
|
+
name='group',
|
|
19
|
+
options={'ordering': ['slug']},
|
|
20
|
+
),
|
|
21
|
+
migrations.AlterModelOptions(
|
|
22
|
+
name='role',
|
|
23
|
+
options={'ordering': ['slug']},
|
|
24
|
+
),
|
|
25
|
+
migrations.AlterModelOptions(
|
|
26
|
+
name='rolegrant',
|
|
27
|
+
options={'ordering': ['role__slug', 'group__slug']},
|
|
28
|
+
),
|
|
29
|
+
migrations.AddIndex(
|
|
30
|
+
model_name='group',
|
|
31
|
+
index=models.Index(fields=['slug'], name='permissions_slug_ed0901_idx'),
|
|
32
|
+
),
|
|
33
|
+
]
|
|
File without changes
|
|
@@ -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,201 @@
|
|
|
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
|
+
class ScopeAnyPermission(BasePermission):
|
|
49
|
+
"""
|
|
50
|
+
Permission class for checking if user has at least one of multiple permissions.
|
|
51
|
+
|
|
52
|
+
Vérifie si l'utilisateur possède au moins une des permissions fournies.
|
|
53
|
+
Utilise any_permission_check pour une vérification optimisée en une seule requête.
|
|
54
|
+
|
|
55
|
+
Example:
|
|
56
|
+
@api_controller('/articles', permissions=[
|
|
57
|
+
ScopeAnyPermission('articles:r', 'articles:w:staff', 'articles:d:admin')
|
|
58
|
+
])
|
|
59
|
+
class ArticleController:
|
|
60
|
+
# User needs either read access, OR staff write access, OR admin delete access
|
|
61
|
+
pass
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, *perms: str):
|
|
65
|
+
"""
|
|
66
|
+
Initialize the permission checker with multiple permission strings.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
*perms: Variable number of permission strings in format "<scope>:<actions>:<group>?context"
|
|
70
|
+
"""
|
|
71
|
+
if not perms:
|
|
72
|
+
raise ValueError("At least one permission string must be provided")
|
|
73
|
+
self.perms = perms
|
|
74
|
+
|
|
75
|
+
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
|
|
76
|
+
"""
|
|
77
|
+
Check if the user has at least one of the required permissions.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
request: HTTP request object
|
|
81
|
+
controller: Controller instance
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
True if user has at least one permission, False otherwise
|
|
85
|
+
"""
|
|
86
|
+
from oxutils.permissions.caches import cache_any_permission_check
|
|
87
|
+
return cache_any_permission_check(request.user, *self.perms)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ScopeAnyActionPermission(BasePermission):
|
|
91
|
+
"""
|
|
92
|
+
Permission class for checking if user has at least one of multiple actions on a scope.
|
|
93
|
+
|
|
94
|
+
Vérifie si l'utilisateur possède au moins une des actions requises pour un scope donné.
|
|
95
|
+
La chaîne d'actions contient plusieurs actions dont au moins une est requise.
|
|
96
|
+
|
|
97
|
+
Example:
|
|
98
|
+
@api_controller('/articles', permissions=[
|
|
99
|
+
ScopeAnyActionPermission('articles:rwd:staff')
|
|
100
|
+
])
|
|
101
|
+
class ArticleController:
|
|
102
|
+
# User needs read OR write OR delete access on articles in staff group
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
@api_controller('/invoices', permissions=[
|
|
106
|
+
ScopeAnyActionPermission('invoices:rw?tenant_id=123')
|
|
107
|
+
])
|
|
108
|
+
class InvoiceController:
|
|
109
|
+
# User needs read OR write access on invoices with tenant_id=123
|
|
110
|
+
pass
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(self, perm: str, ctx: Optional[dict] = None):
|
|
114
|
+
"""
|
|
115
|
+
Initialize the permission checker with a permission string.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
perm: Permission string in format "<scope>:<actions>:<group>?context"
|
|
119
|
+
where actions contains multiple characters (e.g., 'rwd' for read OR write OR delete)
|
|
120
|
+
ctx: Optional additional context dict
|
|
121
|
+
"""
|
|
122
|
+
if not perm:
|
|
123
|
+
raise ValueError("Permission string must be provided")
|
|
124
|
+
|
|
125
|
+
self.perm = perm
|
|
126
|
+
self.ctx = ctx if ctx else dict()
|
|
127
|
+
|
|
128
|
+
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
|
|
129
|
+
"""
|
|
130
|
+
Check if the user has at least one of the required actions.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
request: HTTP request object
|
|
134
|
+
controller: Controller instance
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
True if user has at least one action, False otherwise
|
|
138
|
+
"""
|
|
139
|
+
from oxutils.permissions.caches import cache_any_action_check
|
|
140
|
+
from oxutils.permissions.utils import parse_permission
|
|
141
|
+
|
|
142
|
+
scope, actions, group, query_context = parse_permission(self.perm)
|
|
143
|
+
final_context = {**query_context, **self.ctx}
|
|
144
|
+
|
|
145
|
+
return cache_any_action_check(
|
|
146
|
+
request.user,
|
|
147
|
+
scope,
|
|
148
|
+
actions,
|
|
149
|
+
group,
|
|
150
|
+
**final_context
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def access_manager(actions: str):
|
|
155
|
+
"""
|
|
156
|
+
Factory function for creating ScopePermission instances for access manager.
|
|
157
|
+
|
|
158
|
+
Builds a permission string from settings:
|
|
159
|
+
- ACCESS_MANAGER_SCOPE: The scope to check
|
|
160
|
+
- ACCESS_MANAGER_GROUP: Optional group filter
|
|
161
|
+
- ACCESS_MANAGER_CONTEXT: Optional context dict converted to query params
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
actions: Actions required (e.g., 'r', 'rw', 'rwd')
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
ScopePermission instance configured with access manager settings
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
ImproperlyConfigured: If required settings are missing
|
|
171
|
+
|
|
172
|
+
Example:
|
|
173
|
+
@api_controller('/access', permissions=[access_manager('w')])
|
|
174
|
+
class AccessController:
|
|
175
|
+
pass
|
|
176
|
+
"""
|
|
177
|
+
# Validate required settings
|
|
178
|
+
if not hasattr(settings, 'ACCESS_MANAGER_SCOPE'):
|
|
179
|
+
raise ImproperlyConfigured(
|
|
180
|
+
'ACCESS_MANAGER_SCOPE is not defined. '
|
|
181
|
+
'Add ACCESS_MANAGER_SCOPE = "access" to your settings.'
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Build base permission string: scope:actions
|
|
185
|
+
perm = f"{settings.ACCESS_MANAGER_SCOPE}:{actions}"
|
|
186
|
+
|
|
187
|
+
# Add group if defined and not None
|
|
188
|
+
if hasattr(settings, 'ACCESS_MANAGER_GROUP') and settings.ACCESS_MANAGER_GROUP is not None:
|
|
189
|
+
perm += f":{settings.ACCESS_MANAGER_GROUP}"
|
|
190
|
+
|
|
191
|
+
# Get context if defined and not empty
|
|
192
|
+
context = {}
|
|
193
|
+
if hasattr(settings, 'ACCESS_MANAGER_CONTEXT') and settings.ACCESS_MANAGER_CONTEXT:
|
|
194
|
+
context = settings.ACCESS_MANAGER_CONTEXT
|
|
195
|
+
if not isinstance(context, dict):
|
|
196
|
+
raise ImproperlyConfigured(
|
|
197
|
+
'ACCESS_MANAGER_CONTEXT must be a dictionary. '
|
|
198
|
+
f'Got {type(context).__name__} instead.'
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
return ScopePermission(perm, context)
|