diff --git a/docker/requirements.txt b/docker/requirements.txt index 5731b5760..54c02d833 100644 --- a/docker/requirements.txt +++ b/docker/requirements.txt @@ -54,6 +54,8 @@ django-environ==0.10.0 # via promgen (pyproject.toml) django-filter==23.2 # via promgen (pyproject.toml) +django-guardian==2.4.0 + # via promgen (pyproject.toml) djangorestframework==3.14.0 # via promgen (pyproject.toml) gunicorn==22.0.0 diff --git a/promgen/admin.py b/promgen/admin.py index 1d90c12fb..79bee7b5a 100644 --- a/promgen/admin.py +++ b/promgen/admin.py @@ -2,6 +2,7 @@ # These sources are released under the terms of the MIT license: see LICENSE import json +import guardian.admin from django import forms from django.contrib import admin from django.utils.html import format_html @@ -35,14 +36,14 @@ class ShardAdmin(admin.ModelAdmin): @admin.register(models.Service) -class ServiceAdmin(admin.ModelAdmin): +class ServiceAdmin(guardian.admin.GuardedModelAdmin): list_display = ("name", "owner") list_filter = (("owner", admin.RelatedOnlyFieldListFilter),) list_select_related = ("owner",) @admin.register(models.Project) -class ProjectAdmin(admin.ModelAdmin): +class ProjectAdmin(guardian.admin.GuardedModelAdmin): list_display = ("name", "shard", "service", "farm", "owner") list_select_related = ("service", "farm", "shard", "owner") list_filter = ("shard", ("owner", admin.RelatedOnlyFieldListFilter)) @@ -68,7 +69,7 @@ class SenderAdmin(admin.ModelAdmin): @admin.register(models.Farm) -class FarmAdmin(admin.ModelAdmin): +class FarmAdmin(guardian.admin.GuardedModelAdmin): list_display = ("name", "source") list_filter = ("source",) diff --git a/promgen/forms.py b/promgen/forms.py index 857213431..71dc6f349 100644 --- a/promgen/forms.py +++ b/promgen/forms.py @@ -6,7 +6,9 @@ from dateutil import parser from django import forms +from django.contrib.auth.models import User from django.core.exceptions import ValidationError +from guardian.shortcuts import get_perms_for_model from promgen import errors, models, plugins, prometheus, validators @@ -254,3 +256,38 @@ def clean(self): if not hosts: raise ValidationError("No valid hosts") self.cleaned_data["hosts"] = list(hosts) + + +class UserPermissionForm(forms.Form): + permission = forms.ChoiceField( + required=True, + label="Permission", + ) + + username = forms.ChoiceField( + required=True, + label="Username", + ) + + def __init__(self, *args, **kwargs): + input_object = kwargs.pop("input_object", None) + super(UserPermissionForm, self).__init__(*args, **kwargs) + if input_object: + self.fields["permission"].choices = self.get_permission_choices(input_object) + self.fields["username"].choices = self.get_user_choices() + + def get_permission_choices(self, input_object): + permissions = get_perms_for_model(input_object) + for permission in permissions: + yield (permission.codename, permission.name) + + def get_user_choices(self): + for u in (User.objects.filter(is_active=True, is_superuser=False) + .exclude(username="AnonymousUser") + .order_by("username")): + if u.first_name: + yield (u.username, f"{u.username} ({u.first_name} {u.last_name})") + elif u.email: + yield (u.username, f"{u.username} ({u.email})") + else: + yield (u.username, u.username) diff --git a/promgen/migrations/0024_alter_farm_name_alter_project_name_alter_rule_name_and_more.py b/promgen/migrations/0024_alter_farm_name_alter_project_name_alter_rule_name_and_more.py new file mode 100644 index 000000000..c3c178d8a --- /dev/null +++ b/promgen/migrations/0024_alter_farm_name_alter_project_name_alter_rule_name_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 4.2.11 on 2025-02-03 02:50 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('promgen', '0023_shard_authorization'), + ] + + operations = [ + migrations.AlterField( + model_name='farm', + name='name', + field=models.CharField(max_length=128, validators=[django.core.validators.RegexValidator('^[\\w][- \\w]+$', 'Unicode letters, numbers, underscores, or hyphens or spaces')]), + ), + migrations.AlterField( + model_name='project', + name='name', + field=models.CharField(max_length=128, unique=True, validators=[django.core.validators.RegexValidator('^[\\w][- \\w]+$', 'Unicode letters, numbers, underscores, or hyphens or spaces')]), + ), + migrations.AlterField( + model_name='rule', + name='name', + field=models.CharField(max_length=128, unique=True, validators=[django.core.validators.RegexValidator('^[a-zA-Z_:][a-zA-Z0-9_:]*$', 'Only alphanumeric characters are allowed.')]), + ), + migrations.AlterField( + model_name='service', + name='name', + field=models.CharField(max_length=128, unique=True, validators=[django.core.validators.RegexValidator('^[\\w][- \\w]+$', 'Unicode letters, numbers, underscores, or hyphens or spaces')]), + ), + migrations.AlterField( + model_name='shard', + name='name', + field=models.CharField(max_length=128, unique=True, validators=[django.core.validators.RegexValidator('^[\\w][- \\w]+$', 'Unicode letters, numbers, underscores, or hyphens or spaces')]), + ), + ] diff --git a/promgen/migrations/0025_farm_owner.py b/promgen/migrations/0025_farm_owner.py new file mode 100644 index 000000000..c45549ead --- /dev/null +++ b/promgen/migrations/0025_farm_owner.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.11 on 2025-02-05 08:11 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('promgen', '0024_alter_farm_name_alter_project_name_alter_rule_name_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='farm', + name='owner', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/promgen/migrations/0026_alter_farm_options_alter_project_options_and_more.py b/promgen/migrations/0026_alter_farm_options_alter_project_options_and_more.py new file mode 100644 index 000000000..b4054cc97 --- /dev/null +++ b/promgen/migrations/0026_alter_farm_options_alter_project_options_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.11 on 2025-02-05 08:59 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('promgen', '0025_farm_owner'), + ] + + operations = [ + migrations.AlterModelOptions( + name='farm', + options={'default_permissions': ('manage', 'edit'), 'ordering': ['name']}, + ), + migrations.AlterModelOptions( + name='project', + options={'default_permissions': ('manage', 'edit'), 'ordering': ['name']}, + ), + migrations.AlterModelOptions( + name='service', + options={'default_permissions': ('manage', 'edit'), 'ordering': ['name']}, + ), + ] diff --git a/promgen/mixins.py b/promgen/mixins.py index 84aaad5f9..b6f624026 100644 --- a/promgen/mixins.py +++ b/promgen/mixins.py @@ -1,11 +1,12 @@ # Copyright (c) 2019 LINE Corporation # These sources are released under the terms of the MIT license: see LICENSE - +import guardian.mixins +import guardian.utils from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.views import redirect_to_login from django.contrib.contenttypes.models import ContentType -from django.shortcuts import get_object_or_404 +from django.shortcuts import get_object_or_404, redirect from django.views.generic.base import ContextMixin from promgen import models @@ -78,3 +79,67 @@ def get_context_data(self, **kwargs): models.Service, id=self.kwargs["pk"] ) return context + + +class PromgenGuardianPermissionMixin(guardian.mixins.PermissionRequiredMixin): + + def get_check_permission_object(self): + # Override this method to return the object to check permissions for + return self.get_object() + + def get_check_permission_objects(self): + # We only define permission for Service/Project/Farm + # So we need to check the permission for the parent objects in other cases + try: + object = self.get_check_permission_object() + if isinstance(object, models.Farm): + return [object] + elif isinstance(object, models.Host): + return [object, object.farm] + elif isinstance(object, models.Service): + return [object] + elif isinstance(object, models.Project): + return [object, object.service] + elif isinstance(object, models.Exporter) or isinstance(object, models.URL): + return [object.project, object.project.service] + elif isinstance(object, models.Rule) or isinstance(object, models.Sender): + if isinstance(object.content_object, models.Project): + return [object.content_object, object.content_object.service] + else: + return [object.content_object] + except: + return None + + def check_permissions(self, request): + check_permission_objects = self.get_check_permission_objects() + if check_permission_objects is None: + if request.user.is_active and request.user.is_superuser: + return None + return self.on_permission_check_fail(request, None) + # Loop through all the objects to check permissions for + # If any of the objects has the required permission (any_perm=True), we can proceed + # Otherwise, we will return the forbidden response + forbidden = None + for obj in check_permission_objects: + forbidden = guardian.utils.get_40x_or_None(request, + perms=self.get_required_permissions( + request), + obj=obj, + login_url=self.login_url, + redirect_field_name=self.redirect_field_name, + return_403=self.return_403, + return_404=self.return_404, + accept_global_perms=False, + any_perm=True, + ) + if forbidden is None: + break + if forbidden: + return self.on_permission_check_fail(request, forbidden) + + def on_permission_check_fail(self, request, response, obj=None): + messages.warning(request, "You do not have permission to perform this action.") + referer = request.META.get("HTTP_REFERER") + if referer: + return redirect(referer) + return redirect_to_login(self.request.get_full_path()) diff --git a/promgen/models.py b/promgen/models.py index d99467a82..3993fd9b3 100644 --- a/promgen/models.py +++ b/promgen/models.py @@ -220,6 +220,7 @@ class Service(models.Model): class Meta: ordering = ["name"] + default_permissions = ("manage", "edit") def get_absolute_url(self): return reverse("service-detail", kwargs={"pk": self.pk}) @@ -255,6 +256,7 @@ class Project(models.Model): class Meta: ordering = ["name"] + default_permissions = ("manage", "edit") def get_absolute_url(self): return reverse("project-detail", kwargs={"pk": self.pk}) @@ -266,10 +268,14 @@ def __str__(self): class Farm(models.Model): name = models.CharField(max_length=128, validators=[validators.labelvalue]) source = models.CharField(max_length=128) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, default=None + ) class Meta: ordering = ["name"] unique_together = (("name", "source"),) + default_permissions = ("manage", "edit") def get_absolute_url(self): return reverse("farm-detail", kwargs={"pk": self.pk}) diff --git a/promgen/settings.py b/promgen/settings.py index 190385e29..505591865 100644 --- a/promgen/settings.py +++ b/promgen/settings.py @@ -65,6 +65,7 @@ "rest_framework.authtoken", "rest_framework", "social_django", + "guardian", # Django "django.forms", "django.contrib.admin", @@ -222,3 +223,8 @@ globals()[k] = v DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + +AUTHENTICATION_BACKENDS = ( + "django.contrib.auth.backends.ModelBackend", + "guardian.backends.ObjectPermissionBackend", +) \ No newline at end of file diff --git a/promgen/signals.py b/promgen/signals.py index 2116035fa..3827b6399 100644 --- a/promgen/signals.py +++ b/promgen/signals.py @@ -10,6 +10,7 @@ from django.core.cache import cache from django.db.models.signals import post_delete, post_save, pre_delete, pre_save from django.dispatch import Signal, receiver +from guardian.shortcuts import assign_perm, UserObjectPermission, GroupObjectPermission from promgen import models, prometheus, tasks @@ -333,3 +334,27 @@ def add_default_project_subscription(instance, created, **kwargs): value=instance.owner.username, defaults={"owner": instance.owner}, ) + + +@skip_raw +def assign_manage_permission(sender, instance, created, **kwargs): + # assign manage permission to the owner of the instance when it is created + if created and instance.owner: + assign_perm("manage_" + sender._meta.model_name, instance.owner, instance) + + +post_save.connect(assign_manage_permission, sender=models.Service) +post_save.connect(assign_manage_permission, sender=models.Project) +post_save.connect(assign_manage_permission, sender=models.Farm) + + +@skip_raw +def cleanup_permissions(sender, instance, **kwargs): + # remove all object permissions when the instance is deleted + UserObjectPermission.objects.filter(object_pk=instance.pk).delete() + GroupObjectPermission.objects.filter(object_pk=instance.pk).delete() + + +post_delete.connect(cleanup_permissions, sender=models.Service) +post_delete.connect(cleanup_permissions, sender=models.Project) +post_delete.connect(cleanup_permissions, sender=models.Farm) diff --git a/promgen/templates/promgen/farm_detail.html b/promgen/templates/promgen/farm_detail.html index 8e031433d..c8914470d 100644 --- a/promgen/templates/promgen/farm_detail.html +++ b/promgen/templates/promgen/farm_detail.html @@ -4,7 +4,11 @@ {% block content %}