oxutils 0.1.5__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.5.dist-info → oxutils-0.1.12.dist-info}/METADATA +21 -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.5.dist-info/RECORD +0 -88
- /oxutils/{s3 → pagination}/__init__.py +0 -0
- {oxutils-0.1.5.dist-info → oxutils-0.1.12.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,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
|