From ffa7cae80956a2dda9b11b136c75965152a0e644 Mon Sep 17 00:00:00 2001 From: hoang Date: Thu, 6 Feb 2025 16:40:27 +0700 Subject: [PATCH 01/26] Add Permission Checks for Promgen Web We have created a mixin class to check permission logic of Promgen's models: - For Service and Group, the object being checked is itself. - For Project and Farm, the object being checked is itself or its parent Service. - For Exporter, URL and Host, the object being checked is its parent Project or the Service that is the parent of the Project. - For Rule/Sender, the object being checked is its parent Service or Project. - Other cases only have permission if the user being checked is a superuser. This class has been applied to Promgen's View classes: - The 'ServiceRegister' class is not applied, therefore any user can create a new service. - Users need to have Viewer, Editor, or Admin roles to perform 'View' actions. - 'Update' actions are available to users with Editor or Admin roles. - Only users with the Admin role can perform 'Delete' actions or 'Manage permissions'. --- promgen/mixins.py | 86 ++++++++++++- promgen/tests/test_host_add.py | 11 +- promgen/tests/test_mixins.py | 84 +++++++++++++ promgen/tests/test_routes.py | 7 +- promgen/tests/test_web.py | 83 +++++++++++-- promgen/views.py | 220 +++++++++++++++++++++++++++------ 6 files changed, 436 insertions(+), 55 deletions(-) create mode 100644 promgen/tests/test_mixins.py diff --git a/promgen/mixins.py b/promgen/mixins.py index 84aaad5f9..f80fe506f 100644 --- a/promgen/mixins.py +++ b/promgen/mixins.py @@ -1,14 +1,16 @@ # 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.models import User 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 +from promgen import models, views class ContentTypeMixin: @@ -78,3 +80,81 @@ 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 codes for Service, Group and Project + # 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.Service) or isinstance(object, models.Group): + return [object] + if isinstance(object, models.Project): + return [object, object.service] + if ( + isinstance(object, models.Exporter) + or isinstance(object, models.URL) + or isinstance(object, models.Farm) + ): + return [object.project, object.project.service] + if isinstance(object, models.Host): + return [object.farm.project, object.farm.project.service] + if 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] + return None + except Exception: + return None + + def check_permissions(self, request): + # Always allow user to view the site rule + if isinstance(self, views.RuleDetail) and isinstance( + self.get_check_permission_object().content_object, models.Site + ): + return None + + 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: + # Users always have permission on themselves + if isinstance(obj, User) and request.user == obj: + break + + 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) + return None + + 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/tests/test_host_add.py b/promgen/tests/test_host_add.py index 41c52d0e0..992c79f04 100644 --- a/promgen/tests/test_host_add.py +++ b/promgen/tests/test_host_add.py @@ -1,10 +1,11 @@ # Copyright (c) 2017 LINE Corporation # These sources are released under the terms of the MIT license: see LICENSE - - +from django.shortcuts import get_object_or_404 from django.urls import reverse +from guardian.shortcuts import assign_perm from promgen import models, validators +from promgen.middleware import get_current_user from promgen.tests import PromgenTest @@ -16,6 +17,9 @@ def setUp(self): # separated and comma separated work, but are not necessarily testing # valid/invalid hostnames def test_newline(self): + assign_perm( + "promgen.project_editor", get_current_user(), get_object_or_404(models.Project, pk=1) + ) self.client.post( reverse("hosts-add", args=[1]), {"hosts": "\naaa.example.com\nbbb.example.com\nccc.example.com \n"}, @@ -24,6 +28,9 @@ def test_newline(self): self.assertCount(models.Host, 3, "Expected 3 hosts") def test_comma(self): + assign_perm( + "promgen.project_editor", get_current_user(), get_object_or_404(models.Project, pk=1) + ) self.client.post( reverse("hosts-add", args=[1]), {"hosts": ",,aaa.example.com, bbb.example.com,ccc.example.com,"}, diff --git a/promgen/tests/test_mixins.py b/promgen/tests/test_mixins.py new file mode 100644 index 000000000..7bdf2388d --- /dev/null +++ b/promgen/tests/test_mixins.py @@ -0,0 +1,84 @@ +# Copyright (c) 2025 LINE Corporation +# These sources are released under the terms of the MIT license: see LICENSE +from unittest.mock import patch + +from django.contrib.auth.models import Permission +from django.shortcuts import get_object_or_404 +from django.test import RequestFactory +from guardian.shortcuts import assign_perm + +from promgen import models, tests +from promgen.mixins import PromgenGuardianPermissionMixin + + +class MockView(PromgenGuardianPermissionMixin): + def get_object(self): + return self.object + + def dispatch(self, request, *args, **kwargs): + self.request = request + response = self.check_permissions(request) + if response: + return "Permission Denied" + return "Permission Granted" + + +class PromgenGuardianPermissionMixinTest(tests.PromgenTest): + def setUp(self): + self.view = MockView() + factory = RequestFactory() + self.request = factory.get("/") + + def test_permission_granted(self): + user = self.force_login(username="demo") + object = get_object_or_404(models.Project, pk=1) + permission_required = Permission.objects.get( + codename="project_admin", content_type__model="project" + ) + assign_perm(permission_required, user, object) + self.view.permission_required = permission_required.codename + self.view.object = object + self.request.user = user + response = self.view.dispatch(self.request) + self.assertEqual(response, "Permission Granted") + + @patch("django.contrib.messages.api.add_message") + def test_permission_not_granted(self, mock_add_message): + user = self.force_login(username="demo") + object = get_object_or_404(models.Project, pk=1) + permission_required = Permission.objects.get( + codename="project_admin", content_type__model="project" + ) + self.view.permission_required = permission_required.codename + self.view.object = object + self.request.user = user + response = self.view.dispatch(self.request) + self.assertEqual(response, "Permission Denied") + + def test_permission_granted_on_parent_object(self): + user = self.force_login(username="demo") + object = get_object_or_404(models.Service, pk=1) + permission_required = Permission.objects.get( + codename="service_admin", content_type__model="service" + ) + assign_perm(permission_required, user, object) + self.view.permission_required = permission_required.codename + self.view.object = object + self.request.user = user + response = self.view.dispatch(self.request) + self.assertEqual(response, "Permission Granted") + + @patch("django.contrib.messages.api.add_message") + def test_permission_granted_on_another_object(self, mock_add_message): + user = self.force_login(username="demo") + object = get_object_or_404(models.Service, pk=1) + another_object = models.Service.objects.create(name="Another Service", owner=user) + permission_required = Permission.objects.get( + codename="service_admin", content_type__model="service" + ) + assign_perm(permission_required, user, another_object) + self.view.permission_required = permission_required.codename + self.view.object = object + self.request.user = user + response = self.view.dispatch(self.request) + self.assertEqual(response, "Permission Denied") diff --git a/promgen/tests/test_routes.py b/promgen/tests/test_routes.py index 01cfea3f2..c14b85be9 100644 --- a/promgen/tests/test_routes.py +++ b/promgen/tests/test_routes.py @@ -7,6 +7,7 @@ from django.urls import reverse from promgen import models, tests, views +from promgen.middleware import get_current_user TEST_SETTINGS = tests.Data("examples", "promgen.yml").yaml() TEST_IMPORT = tests.Data("examples", "import.json").raw() @@ -104,7 +105,11 @@ def test_failed_permission(self): self.assertTrue(response.url.startswith("/login")) def test_other_routes(self): - self.add_user_permissions("promgen.add_rule", "promgen.change_site") + user = get_current_user() + user.is_superuser = True + user.save() for request in [{"viewname": "rule-new", "args": ("site", 1)}]: response = self.client.get(reverse(**request)) self.assertRoute(response, views.AlertRuleRegister, 200) + user.is_superuser = False + user.save() diff --git a/promgen/tests/test_web.py b/promgen/tests/test_web.py index e5ebf2cc4..9b9550947 100644 --- a/promgen/tests/test_web.py +++ b/promgen/tests/test_web.py @@ -1,11 +1,13 @@ # Copyright (c) 2022 LINE Corporation # These sources are released under the terms of the MIT license: see LICENSE from django.urls import reverse +from guardian.shortcuts import assign_perm, remove_perm -from promgen import models, tests, views +from promgen import models, views +from promgen.tests import PromgenTest -class WebTests(tests.PromgenTest): +class WebTests(PromgenTest): fixtures = ["testcases.yaml", "extras.yaml"] route_map = [ @@ -13,15 +15,69 @@ class WebTests(tests.PromgenTest): ("datasource-list", views.DatasourceList, {}), ("datasource-detail", views.DatasourceDetail, {"pk": 1}), ("service-list", views.ServiceList, {}), - ("service-detail", views.ServiceDetail, {"pk": 1}), - ("project-detail", views.ProjectDetail, {"pk": 1}), - ("project-exporter", views.ExporterRegister, {"pk": 1}), - ("project-notifier", views.ProjectNotifierRegister, {"pk": 1}), + ( + "service-detail", + views.ServiceDetail, + { + "pk": 1, + "permission": "service_viewer", + "model": models.Service, + "permission_object_pk": 1, + }, + ), + ( + "project-detail", + views.ProjectDetail, + { + "pk": 1, + "permission": "project_viewer", + "model": models.Project, + "permission_object_pk": 1, + }, + ), + ( + "project-exporter", + views.ExporterRegister, + { + "pk": 1, + "permission": "project_editor", + "model": models.Project, + "permission_object_pk": 1, + }, + ), + ( + "project-notifier", + views.ProjectNotifierRegister, + { + "pk": 1, + "permission": "project_editor", + "model": models.Project, + "permission_object_pk": 1, + }, + ), ("url-list", views.URLList, {}), ("farm-list", views.FarmList, {}), - ("farm-detail", views.FarmDetail, {"pk": 1}), + ( + "farm-detail", + views.FarmDetail, + { + "pk": 1, + "permission": "project_viewer", + "model": models.Project, + "permission_object_pk": 1, + }, + ), ("host-list", views.HostList, {}), - ("host-detail", views.HostDetail, {"slug": "example.com"}), + ( + "host-detail", + views.HostDetail, + { + "slug": "example.com", + "permission": "project_viewer", + "model": models.Project, + "permission_object_pk": 1, + }, + ), ("rules-list", views.RulesList, {}), ("rule-detail", views.RuleDetail, {"pk": 1}), ("audit-list", views.AuditList, {}), @@ -39,6 +95,13 @@ def setUp(self): def test_routes(self): for viewname, viewclass, params in self.route_map: + permission = params.pop("permission", None) + permission_model = params.pop("model", None) + permission_object_pk = params.pop("permission_object_pk", None) + if permission and permission_model and permission_object_pk: + permission_object = permission_model.objects.get(pk=permission_object_pk) + assign_perm(permission, self.user, permission_object) + # By default we'll pass all params as-is to our reverse() # method, but we may have a few special ones (like status_code) # that we want to pop and handle separately @@ -49,6 +112,10 @@ def test_routes(self): response = self.client.get(reverse(viewname, kwargs=params)) self.assertRoute(response, viewclass, status_code) + if permission and permission_model and permission_object_pk: + permission_object = permission_model.objects.get(pk=permission_object_pk) + remove_perm(permission, self.user, permission_object) + def test_delete_project_without_farm(self): # Create a project without associating it with a farm shard = models.Shard.objects.get(pk=1) diff --git a/promgen/views.py b/promgen/views.py index 514697924..ab0b66552 100644 --- a/promgen/views.py +++ b/promgen/views.py @@ -49,6 +49,7 @@ util, ) from promgen.forms import GroupMemberForm, UserPermissionForm +from promgen.mixins import PromgenGuardianPermissionMixin from promgen.shortcuts import resolve_domain logger = logging.getLogger(__name__) @@ -250,7 +251,8 @@ def get_queryset(self): paginate_by = 50 -class ServiceDetail(LoginRequiredMixin, DetailView): +class ServiceDetail(PromgenGuardianPermissionMixin, DetailView): + permission_required = ["service_admin", "service_editor", "service_viewer"] queryset = models.Service.objects.prefetch_related( "rule_set", "notifiers", @@ -270,21 +272,24 @@ def get_context_data(self, **kwargs): return context -class ServiceDelete(LoginRequiredMixin, DeleteView): +class ServiceDelete(PromgenGuardianPermissionMixin, DeleteView): + permission_required = ["service_admin"] model = models.Service def get_success_url(self): return reverse("service-list") -class ProjectDelete(LoginRequiredMixin, DeleteView): +class ProjectDelete(PromgenGuardianPermissionMixin, DeleteView): + permission_required = ["service_admin", "project_admin"] model = models.Project def get_success_url(self): return reverse("service-detail", args=[self.object.service_id]) -class NotifierUpdate(LoginRequiredMixin, UpdateView): +class NotifierUpdate(PromgenGuardianPermissionMixin, UpdateView): + permission_required = ["service_admin", "service_editor", "project_admin", "project_editor"] model = models.Sender form_class = forms.NotifierUpdate @@ -335,7 +340,8 @@ def post(self, request, pk): return self.get(self, request, pk) -class NotifierDelete(LoginRequiredMixin, DeleteView): +class NotifierDelete(PromgenGuardianPermissionMixin, DeleteView): + permission_required = ["service_admin", "service_editor", "project_admin", "project_editor"] model = models.Sender def get_success_url(self): @@ -346,7 +352,9 @@ def get_success_url(self): return reverse("profile") -class NotifierTest(LoginRequiredMixin, View): +class NotifierTest(PromgenGuardianPermissionMixin, View): + permission_required = ["service_admin", "service_editor", "project_admin", "project_editor"] + def post(self, request, pk): sender = get_object_or_404(models.Sender, id=pk) try: @@ -362,15 +370,21 @@ def post(self, request, pk): return redirect(sender.content_object) return redirect("profile") + def get_check_permission_object(self): + return get_object_or_404(models.Sender, id=self.kwargs["pk"]) + -class ExporterDelete(LoginRequiredMixin, DeleteView): +class ExporterDelete(PromgenGuardianPermissionMixin, DeleteView): + permission_required = ["service_admin", "service_editor", "project_admin", "project_editor"] model = models.Exporter def get_success_url(self): return reverse("project-detail", args=[self.object.project_id]) + "#exporters" -class ExporterToggle(LoginRequiredMixin, View): +class ExporterToggle(PromgenGuardianPermissionMixin, View): + permission_required = ["service_admin", "service_editor", "project_admin", "project_editor"] + def post(self, request, pk): exporter = get_object_or_404(models.Exporter, id=pk) exporter.enabled = not exporter.enabled @@ -378,8 +392,17 @@ def post(self, request, pk): signals.trigger_write_config.send(request) return JsonResponse({"redirect": exporter.project.get_absolute_url() + "#exporters"}) + def get_check_permission_object(self): + return get_object_or_404(models.Exporter, id=self.kwargs["pk"]) + + def on_permission_check_fail(self, request, response, obj=None): + messages.warning(request, "You do not have permission to perform this action.") + return JsonResponse({"redirect": "#exporters"}) + + +class NotifierToggle(PromgenGuardianPermissionMixin, View): + permission_required = ["service_admin", "service_editor", "project_admin", "project_editor"] -class NotifierToggle(LoginRequiredMixin, View): def post(self, request, pk): sender = get_object_or_404(models.Sender, id=pk) sender.enabled = not sender.enabled @@ -387,8 +410,16 @@ def post(self, request, pk): # Redirect to current page return JsonResponse({"redirect": "#notifiers"}) + def get_check_permission_object(self): + return get_object_or_404(models.Sender, id=self.kwargs["pk"]) + + def on_permission_check_fail(self, request, response, obj=None): + messages.warning(request, "You do not have permission to perform this action.") + return JsonResponse({"redirect": "#notifiers"}) -class RuleDelete(mixins.PromgenPermissionMixin, DeleteView): + +class RuleDelete(PromgenGuardianPermissionMixin, DeleteView): + permission_required = ["service_admin", "service_editor", "project_admin", "project_editor"] model = models.Rule def get_permission_denied_message(self): @@ -408,7 +439,8 @@ def get_success_url(self): return self.object.content_object.get_absolute_url() + "#rules" -class RuleToggle(mixins.PromgenPermissionMixin, SingleObjectMixin, View): +class RuleToggle(PromgenGuardianPermissionMixin, SingleObjectMixin, View): + permission_required = ["service_admin", "service_editor", "project_admin", "project_editor"] model = models.Rule def get_permission_denied_message(self): @@ -429,15 +461,28 @@ def post(self, request, pk): self.object.save() return JsonResponse({"redirect": self.object.content_object.get_absolute_url() + "#rules"}) + def on_permission_check_fail(self, request, response, obj=None): + messages.warning(request, "You do not have permission to perform this action.") + return JsonResponse({"redirect": "#rules"}) + -class HostDelete(LoginRequiredMixin, DeleteView): +class HostDelete(PromgenGuardianPermissionMixin, DeleteView): + permission_required = ["project_admin", "service_admin", "project_editor", "service_editor"] model = models.Host def get_success_url(self): return self.object.farm.get_absolute_url() -class ProjectDetail(LoginRequiredMixin, DetailView): +class ProjectDetail(PromgenGuardianPermissionMixin, DetailView): + permission_required = [ + "service_admin", + "service_editor", + "service_viewer", + "project_admin", + "project_editor", + "project_viewer", + ] queryset = models.Project.objects.prefetch_related( "rule_set", "rule_set__parent", @@ -472,7 +517,15 @@ class FarmList(LoginRequiredMixin, ListView): ) -class FarmDetail(LoginRequiredMixin, DetailView): +class FarmDetail(PromgenGuardianPermissionMixin, DetailView): + permission_required = [ + "project_admin", + "service_admin", + "project_editor", + "service_editor", + "project_viewer", + "service_viewer", + ] model = models.Farm def get_context_data(self, **kwargs): @@ -481,7 +534,8 @@ def get_context_data(self, **kwargs): return context -class FarmUpdate(LoginRequiredMixin, UpdateView): +class FarmUpdate(PromgenGuardianPermissionMixin, UpdateView): + permission_required = ["project_admin", "service_admin", "project_editor", "service_editor"] model = models.Farm button_label = _("Update Farm") template_name = "promgen/farm_update.html" @@ -501,7 +555,8 @@ def form_valid(self, form): return redirect("farm-detail", pk=farm.id) -class FarmDelete(LoginRequiredMixin, RedirectView): +class FarmDelete(PromgenGuardianPermissionMixin, RedirectView): + permission_required = ["service_admin", "service_editor", "project_admin", "project_editor"] pattern_name = "farm-detail" def post(self, request, pk): @@ -510,8 +565,13 @@ def post(self, request, pk): return HttpResponseRedirect(request.POST.get("next", reverse("farm-list"))) + def get_check_permission_object(self): + return get_object_or_404(models.Farm, id=self.kwargs["pk"]) + + +class UnlinkFarm(PromgenGuardianPermissionMixin, View): + permission_required = ["service_admin", "service_editor", "project_admin", "project_editor"] -class UnlinkFarm(LoginRequiredMixin, View): def post(self, request, pk): project = get_object_or_404(models.Project, id=pk) oldfarm, project.farm = project.farm, None @@ -522,6 +582,9 @@ def post(self, request, pk): return HttpResponseRedirect(reverse("project-detail", args=[project.id]) + "#hosts") + def get_check_permission_object(self): + return get_object_or_404(models.Project, id=self.kwargs["pk"]) + class RulesList(LoginRequiredMixin, ListView, mixins.ServiceMixin): paginate_by = 50 @@ -559,7 +622,9 @@ def get_context_data(self, **kwargs): return context -class RulesCopy(LoginRequiredMixin, View): +class RulesCopy(PromgenGuardianPermissionMixin, View): + permission_required = ["service_admin", "service_editor", "project_admin", "project_editor"] + def post(self, request, pk): original = get_object_or_404(models.Rule, id=pk) form = forms.RuleCopyForm(request.POST) @@ -570,6 +635,13 @@ def post(self, request, pk): else: return HttpResponseRedirect(reverse("service-detail", args=[pk]) + "#rules") + def get_check_permission_object(self): + content_type = ContentType.objects.get( + app_label="promgen", model=self.request.POST["content_type"] + ) + model_class = content_type.model_class() + return model_class.objects.get(pk=self.request.POST["object_id"]) + class FarmRefresh(LoginRequiredMixin, RedirectView): pattern_name = "farm-detail" @@ -588,7 +660,8 @@ def post(self, request, pk): return redirect(farm) -class FarmConvert(LoginRequiredMixin, RedirectView): +class FarmConvert(PromgenGuardianPermissionMixin, RedirectView): + permission_required = ["project_admin", "service_admin", "project_editor", "service_editor"] pattern_name = "farm-detail" def post(self, request, pk): @@ -612,8 +685,13 @@ def post(self, request, pk): request.POST.get("next", reverse("farm-detail", args=[farm.pk])) ) + def get_check_permission_object(self): + return get_object_or_404(models.Farm, id=self.kwargs["pk"]) + + +class FarmLink(PromgenGuardianPermissionMixin, View): + permission_required = ["service_admin", "service_editor", "project_admin", "project_editor"] -class FarmLink(LoginRequiredMixin, View): def get(self, request, pk, source): if source == discovery.FARM_DEFAULT: messages.error(request, "Cannot link to local farm") @@ -644,8 +722,12 @@ def post(self, request, pk, source): messages.info(request, "Refreshed hosts") return HttpResponseRedirect(reverse("project-detail", args=[project.id]) + "#hosts") + def get_check_permission_object(self): + return get_object_or_404(models.Project, id=self.kwargs["pk"]) + -class ExporterRegister(LoginRequiredMixin, FormView, mixins.ProjectMixin): +class ExporterRegister(PromgenGuardianPermissionMixin, FormView, mixins.ProjectMixin): + permission_required = ["service_admin", "service_editor", "project_admin", "project_editor"] model = models.Exporter template_name = "promgen/exporter_form.html" form_class = forms.ExporterForm @@ -655,6 +737,9 @@ def form_valid(self, form): exporter, _ = models.Exporter.objects.get_or_create(project=project, **form.clean()) return HttpResponseRedirect(reverse("project-detail", args=[project.id]) + "#exporters") + def get_check_permission_object(self): + return get_object_or_404(models.Project, id=self.kwargs["pk"]) + class ExporterScrape(LoginRequiredMixin, View): # TODO: Move to /rest/project//scrape @@ -711,7 +796,8 @@ def query(): return JsonResponse({"error": "Error with query %s" % e}) -class URLRegister(LoginRequiredMixin, FormView, mixins.ProjectMixin): +class URLRegister(PromgenGuardianPermissionMixin, FormView, mixins.ProjectMixin): + permission_required = ["service_admin", "service_editor", "project_admin", "project_editor"] model = models.URL form_class = forms.URLForm @@ -720,8 +806,12 @@ def form_valid(self, form): url, _ = models.URL.objects.get_or_create(project=project, **form.clean()) return HttpResponseRedirect(reverse("project-detail", args=[project.id]) + "#http-checks") + def get_check_permission_object(self): + return get_object_or_404(models.Project, id=self.kwargs["pk"]) + -class URLDelete(LoginRequiredMixin, DeleteView): +class URLDelete(PromgenGuardianPermissionMixin, DeleteView): + permission_required = ["service_admin", "service_editor", "project_admin", "project_editor"] model = models.URL def get_success_url(self): @@ -737,7 +827,8 @@ class URLList(LoginRequiredMixin, ListView): ) -class ProjectRegister(LoginRequiredMixin, CreateView): +class ProjectRegister(PromgenGuardianPermissionMixin, CreateView): + permission_required = ["service_admin", "service_editor"] button_label = _("Register Project") model = models.Project fields = ["name", "description", "owner", "shard"] @@ -766,8 +857,12 @@ def form_valid(self, form): form.instance.service_id = self.kwargs["pk"] return super().form_valid(form) + def get_check_permission_object(self): + return get_object_or_404(models.Service, id=self.kwargs["pk"]) -class ProjectUpdate(LoginRequiredMixin, UpdateView): + +class ProjectUpdate(PromgenGuardianPermissionMixin, UpdateView): + permission_required = ["service_admin", "service_editor", "project_admin", "project_editor"] model = models.Project button_label = _("Project Update") template_name = "promgen/project_form.html" @@ -790,7 +885,8 @@ def form_valid(self, form): return super().form_valid(form) -class ServiceUpdate(LoginRequiredMixin, UpdateView): +class ServiceUpdate(PromgenGuardianPermissionMixin, UpdateView): + permission_required = ["service_admin", "service_editor"] button_label = _("Update Service") form_class = forms.ServiceUpdate model = models.Service @@ -806,7 +902,15 @@ def form_valid(self, form): return super().form_valid(form) -class RuleDetail(LoginRequiredMixin, DetailView): +class RuleDetail(PromgenGuardianPermissionMixin, DetailView): + permission_required = [ + "service_admin", + "service_editor", + "service_viewer", + "project_admin", + "project_editor", + "project_viewer", + ] queryset = models.Rule.objects.prefetch_related( "content_object", "content_type", @@ -816,7 +920,9 @@ class RuleDetail(LoginRequiredMixin, DetailView): ) -class RuleUpdate(mixins.PromgenPermissionMixin, UpdateView): +class RuleUpdate(PromgenGuardianPermissionMixin, UpdateView): + permission_required = ["service_admin", "service_editor", "project_admin", "project_editor"] + def get_permission_denied_message(self): return "Unable to edit rule %s. User lacks permission" % self.object @@ -881,7 +987,8 @@ def post(self, request, *args, **kwargs): return self.form_valid(context["form"]) -class AlertRuleRegister(mixins.PromgenPermissionMixin, mixins.RuleFormMixin, FormView): +class AlertRuleRegister(PromgenGuardianPermissionMixin, mixins.RuleFormMixin, FormView): + permission_required = ["service_admin", "service_editor", "project_admin", "project_editor"] model = models.Rule template_name = "promgen/rule_register.html" form_class = forms.AlertRuleForm @@ -913,6 +1020,13 @@ def form_import(self, form, content_object): messages.info(self.request, "Imported %s" % counters) return HttpResponseRedirect(content_object.get_absolute_url()) + def get_check_permission_object(self): + id = self.kwargs["object_id"] + model = self.kwargs["content_type"] + models = ContentType.objects.get(app_label="promgen", model=model) + obj = models.get_object_for_this_type(pk=id) + return obj + class ServiceRegister(LoginRequiredMixin, CreateView): button_label = _("Register Service") @@ -932,7 +1046,8 @@ def post(self, request, *args, **kwargs): return super().post(request, *args, **kwargs) -class FarmRegister(LoginRequiredMixin, FormView, mixins.ProjectMixin): +class FarmRegister(PromgenGuardianPermissionMixin, FormView, mixins.ProjectMixin): + permission_required = ["service_admin", "service_editor", "project_admin", "project_editor"] model = models.Farm button_label = _("Register Farm") template_name = "promgen/farm_register.html" @@ -962,8 +1077,12 @@ def form_valid(self, form): return HttpResponseRedirect(project.get_absolute_url() + "#hosts") + def get_check_permission_object(self): + return get_object_or_404(models.Project, id=self.kwargs["pk"]) -class ProjectNotifierRegister(LoginRequiredMixin, FormView, mixins.ProjectMixin): + +class ProjectNotifierRegister(PromgenGuardianPermissionMixin, FormView, mixins.ProjectMixin): + permission_required = ["service_admin", "service_editor", "project_admin", "project_editor"] model = models.Sender template_name = "promgen/notifier_form.html" form_class = forms.SenderForm @@ -978,8 +1097,12 @@ def form_valid(self, form): signals.check_user_subscription(models.Sender, sender, created, self.request) return HttpResponseRedirect(project.get_absolute_url() + "#notifiers") + def get_check_permission_object(self): + return get_object_or_404(models.Project, id=self.kwargs["pk"]) + -class ServiceNotifierRegister(LoginRequiredMixin, FormView, mixins.ServiceMixin): +class ServiceNotifierRegister(PromgenGuardianPermissionMixin, FormView, mixins.ServiceMixin): + permission_required = ["service_admin", "service_editor"] model = models.Sender template_name = "promgen/notifier_form.html" form_class = forms.SenderForm @@ -994,6 +1117,9 @@ def form_valid(self, form): signals.check_user_subscription(models.Sender, sender, created, self.request) return HttpResponseRedirect(service.get_absolute_url() + "#notifiers") + def get_check_permission_object(self): + return get_object_or_404(models.Service, id=self.kwargs["pk"]) + class SiteDetail(LoginRequiredMixin, TemplateView): template_name = "promgen/site_detail.html" @@ -1029,7 +1155,8 @@ def form_valid(self, form): return redirect("profile") -class HostRegister(LoginRequiredMixin, FormView): +class HostRegister(PromgenGuardianPermissionMixin, FormView): + permission_required = ["project_admin", "service_admin", "project_editor", "service_editor"] model = models.Host template_name = "promgen/host_form.html" form_class = forms.HostForm @@ -1048,6 +1175,9 @@ def form_valid(self, form): return redirect("farm-detail", pk=farm.id) + def get_check_permission_object(self): + return get_object_or_404(models.Farm, id=self.kwargs["pk"]) + class ApiConfig(View): def get(self, request): @@ -1517,7 +1647,7 @@ def get(self, request): return redirect("profile") -class PermissionAssign(LoginRequiredMixin, View): +class PermissionAssign(PromgenGuardianPermissionMixin, View): permission_required = ["service_admin", "project_admin"] def post(self, request): @@ -1568,7 +1698,9 @@ def get_object(self): return obj -class PermissionDelete(LoginRequiredMixin, View): +class PermissionDelete(PromgenGuardianPermissionMixin, View): + permission_required = ["service_admin", "project_admin"] + def post(self, request): obj = self.get_object() permission_type = request.POST["perm-type"] @@ -1687,7 +1819,8 @@ class GroupList(LoginRequiredMixin, ListView): queryset = models.Group.objects.exclude(name=settings.PROMGEN_DEFAULT_GROUP).order_by("name") -class GroupDetail(LoginRequiredMixin, DetailView): +class GroupDetail(PromgenGuardianPermissionMixin, DetailView): + permission_required = ["group_admin", "group_member"] queryset = models.Group.objects.exclude(name=settings.PROMGEN_DEFAULT_GROUP) def get_context_data(self, **kwargs): @@ -1697,7 +1830,8 @@ def get_context_data(self, **kwargs): return context -class GroupAddMember(LoginRequiredMixin, SingleObjectMixin, View): +class GroupAddMember(PromgenGuardianPermissionMixin, SingleObjectMixin, View): + permission_required = ["group_admin"] queryset = models.Group.objects.exclude(name=settings.PROMGEN_DEFAULT_GROUP) def post(self, request, *args, **kwargs): @@ -1735,7 +1869,8 @@ def post(self, request, *args, **kwargs): return redirect("group-detail", pk=group.pk) -class GroupUpdateMember(LoginRequiredMixin, SingleObjectMixin, View): +class GroupUpdateMember(PromgenGuardianPermissionMixin, SingleObjectMixin, View): + permission_required = ["group_admin"] queryset = models.Group.objects.exclude(name=settings.PROMGEN_DEFAULT_GROUP) def post(self, request, *args, **kwargs): @@ -1761,7 +1896,8 @@ def post(self, request, *args, **kwargs): return redirect("group-detail", pk=group.pk) -class GroupRemoveMember(LoginRequiredMixin, SingleObjectMixin, View): +class GroupRemoveMember(PromgenGuardianPermissionMixin, SingleObjectMixin, View): + permission_required = ["group_admin"] queryset = models.Group.objects.exclude(name=settings.PROMGEN_DEFAULT_GROUP) def post(self, request, *args, **kwargs): @@ -1810,13 +1946,15 @@ def get_success_url(self): return super().get_success_url() -class GroupUpdate(LoginRequiredMixin, UpdateView): +class GroupUpdate(PromgenGuardianPermissionMixin, UpdateView): + permission_required = ["group_admin"] button_label = _("Update Group") queryset = models.Group.objects.exclude(name=settings.PROMGEN_DEFAULT_GROUP) fields = ["name"] -class GroupDelete(LoginRequiredMixin, DeleteView): +class GroupDelete(PromgenGuardianPermissionMixin, DeleteView): + permission_required = ["group_admin"] button_label = _("Delete Group") queryset = models.Group.objects.exclude(name=settings.PROMGEN_DEFAULT_GROUP) From 3c0466c8af001267d55e043984da6548381c5e36 Mon Sep 17 00:00:00 2001 From: hoang Date: Thu, 8 May 2025 16:55:50 +0700 Subject: [PATCH 02/26] Remove the "Default" group The "Default" group is the current authorization model of Promgen. It is no longer necessary after releasing the per-object permission authorization model. Therefore, the "Default" group and any related parts need to be removed from Promgen's source code. --- promgen/forms.py | 3 +-- promgen/migrations/0003_default-group.py | 2 +- .../migrations/0040_remove_default_group.py | 24 +++++++++++++++++++ promgen/settings.py | 1 - promgen/signals.py | 15 ------------ promgen/views.py | 21 ++++++---------- 6 files changed, 33 insertions(+), 33 deletions(-) create mode 100644 promgen/migrations/0040_remove_default_group.py diff --git a/promgen/forms.py b/promgen/forms.py index 711341609..823209db8 100644 --- a/promgen/forms.py +++ b/promgen/forms.py @@ -6,7 +6,6 @@ from dateutil import parser from django import forms -from django.conf import settings from django.contrib.auth.models import User from django.core.exceptions import ValidationError from guardian.conf.settings import ANONYMOUS_USER_NAME @@ -268,7 +267,7 @@ def get_permission_choices(input_object): def get_group_choices(): yield ("", "") - for g in models.Group.objects.exclude(name=settings.PROMGEN_DEFAULT_GROUP).order_by("name"): + for g in models.Group.objects.order_by("name"): yield (g.name, g.name) diff --git a/promgen/migrations/0003_default-group.py b/promgen/migrations/0003_default-group.py index 2eff59d7a..e59976e21 100644 --- a/promgen/migrations/0003_default-group.py +++ b/promgen/migrations/0003_default-group.py @@ -5,7 +5,7 @@ def create_group(apps, schema_editor): - if not settings.PROMGEN_DEFAULT_GROUP: + if not getattr(settings, "PROMGEN_DEFAULT_GROUP", None): return # Create Default Group diff --git a/promgen/migrations/0040_remove_default_group.py b/promgen/migrations/0040_remove_default_group.py new file mode 100644 index 000000000..bd3068f48 --- /dev/null +++ b/promgen/migrations/0040_remove_default_group.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.11 on 2025-08-14 08:34 + +from django.db import migrations + + +def remove_group(apps, schema_editor): + # Get the Default group + # Note: The group name is hardcoded as "Default" in the original Promgen. + # If the name is different in your application, you should change it + # according to the value used in settings.PROMGEN_DEFAULT_GROUP. + default_group = apps.get_model("auth", "Group").objects.filter(name="Default").first() + + if default_group: + default_group.user_set.clear() + default_group.permissions.clear() + default_group.delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("promgen", "0039_migrate_user_type_sender_to_pk"), + ] + + operations = [migrations.RunPython(remove_group)] diff --git a/promgen/settings.py b/promgen/settings.py index da6d57691..51b5058a3 100644 --- a/promgen/settings.py +++ b/promgen/settings.py @@ -50,7 +50,6 @@ else: PROMGEN = {} -PROMGEN_DEFAULT_GROUP = "Default" PROMGEN_SCHEME = env.str("PROMGEN_SCHEME", default="http") ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=["127.0.0.1", "localhost"]) diff --git a/promgen/signals.py b/promgen/signals.py index eb80d1f30..8f2ce4723 100644 --- a/promgen/signals.py +++ b/promgen/signals.py @@ -6,7 +6,6 @@ from django.conf import settings from django.contrib import messages -from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType from django.core.cache import cache from django.db.models import Q @@ -276,20 +275,6 @@ def save_service(*, sender, instance, **kwargs): return True -@receiver(post_save, sender=settings.AUTH_USER_MODEL) -@skip_raw -def add_user_to_default_group(instance, created, **kwargs): - # If we enabled our default group, then we want to ensure that all newly - # created users are also added to our default group so they inherit the - # default permissions - if not settings.PROMGEN_DEFAULT_GROUP: - return - if not created: - return - - instance.groups.add(Group.objects.get(name=settings.PROMGEN_DEFAULT_GROUP)) - - @receiver(post_save, sender=settings.AUTH_USER_MODEL) @skip_raw def add_email_sender(instance, created, **kwargs): diff --git a/promgen/views.py b/promgen/views.py index ab0b66552..ec545efa3 100644 --- a/promgen/views.py +++ b/promgen/views.py @@ -1427,13 +1427,6 @@ def get(self, request): else: filters = Q(**{field: query_dict[var]}) - # For groups, we want to exclude the default group from search results - if obj["model"] == models.Group: - if filters: - filters &= ~Q(name=settings.PROMGEN_DEFAULT_GROUP) - else: - filters = ~Q(name=settings.PROMGEN_DEFAULT_GROUP) - logger.info("filtering %s by %s", target, filters) qs = qs.filter(filters) @@ -1816,12 +1809,12 @@ def build_warning_message(self, projects, previous_owner): class GroupList(LoginRequiredMixin, ListView): paginate_by = 20 - queryset = models.Group.objects.exclude(name=settings.PROMGEN_DEFAULT_GROUP).order_by("name") + queryset = models.Group.objects.order_by("name") class GroupDetail(PromgenGuardianPermissionMixin, DetailView): permission_required = ["group_admin", "group_member"] - queryset = models.Group.objects.exclude(name=settings.PROMGEN_DEFAULT_GROUP) + model = models.Group def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -1832,7 +1825,7 @@ def get_context_data(self, **kwargs): class GroupAddMember(PromgenGuardianPermissionMixin, SingleObjectMixin, View): permission_required = ["group_admin"] - queryset = models.Group.objects.exclude(name=settings.PROMGEN_DEFAULT_GROUP) + model = models.Group def post(self, request, *args, **kwargs): group = self.get_object() @@ -1871,7 +1864,7 @@ def post(self, request, *args, **kwargs): class GroupUpdateMember(PromgenGuardianPermissionMixin, SingleObjectMixin, View): permission_required = ["group_admin"] - queryset = models.Group.objects.exclude(name=settings.PROMGEN_DEFAULT_GROUP) + model = models.Group def post(self, request, *args, **kwargs): group = self.get_object() @@ -1898,7 +1891,7 @@ def post(self, request, *args, **kwargs): class GroupRemoveMember(PromgenGuardianPermissionMixin, SingleObjectMixin, View): permission_required = ["group_admin"] - queryset = models.Group.objects.exclude(name=settings.PROMGEN_DEFAULT_GROUP) + model = models.Group def post(self, request, *args, **kwargs): group = self.get_object() @@ -1949,14 +1942,14 @@ def get_success_url(self): class GroupUpdate(PromgenGuardianPermissionMixin, UpdateView): permission_required = ["group_admin"] button_label = _("Update Group") - queryset = models.Group.objects.exclude(name=settings.PROMGEN_DEFAULT_GROUP) + model = models.Group fields = ["name"] class GroupDelete(PromgenGuardianPermissionMixin, DeleteView): permission_required = ["group_admin"] button_label = _("Delete Group") - queryset = models.Group.objects.exclude(name=settings.PROMGEN_DEFAULT_GROUP) + model = models.Group def get_success_url(self): return reverse("group-list") From fe81ac609b4fbe7fdc012b16051b3e371ef7b7fa Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 13 May 2025 09:22:31 +0700 Subject: [PATCH 03/26] Replace the DRF's default permission class With the per-object permission authorization model, the default permission class of Django REST Framework, DjangoModelPermissionsOrAnonReadOnly, will no longer be suitable. Therefore, we have replaced it with a new custom class. To keep things simple, the new class will require user authentication on every API and will only allow normal users to use the GET, HEAD, and OPTIONS methods. Filtering data based on user's permissions will be specifically handled at each API. --- promgen/permissions.py | 17 +++++++++++++++++ promgen/rest.py | 4 +--- promgen/settings.py | 2 +- promgen/tests/test_renderers.py | 3 +++ 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/promgen/permissions.py b/promgen/permissions.py index 67c05d6b7..f5ed17965 100644 --- a/promgen/permissions.py +++ b/promgen/permissions.py @@ -1,6 +1,7 @@ # Copyright (c) 2025 LINE Corporation # These sources are released under the terms of the MIT license: see LICENSE from django.utils.itercompat import is_iterable +from rest_framework import permissions from rest_framework.permissions import BasePermission @@ -41,3 +42,19 @@ def has_permission(self, request, view): return any(request.user.has_perm(perm) for perm in perm_list) else: return all(request.user.has_perm(perm) for perm in perm_list) + + +class ReadOnlyForAuthenticatedUserOrIsSuperuser(BasePermission): + """ + Customize Django REST Framework's base permission class to only allow read-only access for + authenticated users and full access for superusers. + """ + + def has_permission(self, request, view): + if request.user.is_superuser: + return True + return bool( + request.user + and request.user.is_authenticated + and request.method in permissions.SAFE_METHODS + ) diff --git a/promgen/rest.py b/promgen/rest.py index 7d4d4c0e1..45ed8ea6b 100644 --- a/promgen/rest.py +++ b/promgen/rest.py @@ -3,7 +3,7 @@ from django.http import HttpResponse from requests.exceptions import HTTPError -from rest_framework import permissions, viewsets +from rest_framework import viewsets from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.views import APIView @@ -28,8 +28,6 @@ def post(self, request, *args, **kwargs): class AllViewSet(viewsets.ViewSet): - permission_classes = [permissions.AllowAny] - @action(detail=False, methods=["get"], renderer_classes=[renderers.RuleRenderer]) def rules(self, request): rules = models.Rule.objects.filter(enabled=True) diff --git a/promgen/settings.py b/promgen/settings.py index 51b5058a3..a9f7c4fb4 100644 --- a/promgen/settings.py +++ b/promgen/settings.py @@ -193,7 +193,7 @@ "rest_framework.authentication.SessionAuthentication", ), "DEFAULT_PERMISSION_CLASSES": ( - "rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly", + "promgen.permissions.ReadOnlyForAuthenticatedUserOrIsSuperuser", ), "DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",), } diff --git a/promgen/tests/test_renderers.py b/promgen/tests/test_renderers.py index 8d725badd..e35b9da24 100644 --- a/promgen/tests/test_renderers.py +++ b/promgen/tests/test_renderers.py @@ -10,6 +10,9 @@ class RendererTests(tests.PromgenTest): fixtures = ["testcases.yaml", "extras.yaml"] + def setUp(self): + self.user = self.force_login(username="admin") + def test_global_rule(self): expected = tests.Data("examples", "export.rule.yml").yaml() response = self.client.get(reverse("api:all-rules")) From 84bf454141a79eb1d27eca638db2612a2314f52a Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 15 Apr 2025 14:51:59 +0700 Subject: [PATCH 04/26] Filter the Services on Home page by the user's permissions We add a filter to the HomeListView to show only the Services that the currently logged-in user has permission to access. --- promgen/permissions.py | 15 +++++++++++++++ promgen/views.py | 6 +++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/promgen/permissions.py b/promgen/permissions.py index f5ed17965..92c54a119 100644 --- a/promgen/permissions.py +++ b/promgen/permissions.py @@ -1,9 +1,13 @@ # Copyright (c) 2025 LINE Corporation # These sources are released under the terms of the MIT license: see LICENSE +from django.contrib.auth.models import User from django.utils.itercompat import is_iterable +from guardian.shortcuts import get_objects_for_user from rest_framework import permissions from rest_framework.permissions import BasePermission +from promgen import models + class PromgenModelPermissions(BasePermission): """ @@ -58,3 +62,14 @@ def has_permission(self, request, view): and request.user.is_authenticated and request.method in permissions.SAFE_METHODS ) + + +def get_accessible_services_for_user(user: User): + return get_objects_for_user( + user, + ["service_admin", "service_editor", "service_viewer"], + any_perm=True, + use_groups=True, + accept_global_perms=False, + klass=models.Service, + ) diff --git a/promgen/views.py b/promgen/views.py index ec545efa3..901b8e2e0 100644 --- a/promgen/views.py +++ b/promgen/views.py @@ -42,6 +42,7 @@ forms, mixins, models, + permissions, plugins, prometheus, signals, @@ -123,7 +124,7 @@ def get_queryset(self): ).values_list("object_id") # and return just our list of services - return models.Service.objects.filter(pk__in=senders).prefetch_related( + query_set = models.Service.objects.filter(pk__in=senders).prefetch_related( "notifiers", "notifiers__owner", "owner", @@ -138,6 +139,9 @@ def get_queryset(self): "project_set__notifiers__owner", ) + services = permissions.get_accessible_services_for_user(self.request.user) + return query_set.filter(pk__in=services) + class HostList(LoginRequiredMixin, ListView): queryset = models.Host.objects.prefetch_related( From 441ad45bc1c980e261a105204d3763486dd7e3f9 Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 22 Apr 2025 08:58:52 +0700 Subject: [PATCH 05/26] Filter the Service list by the user's permissions We add a filter to the ServiceListView to show only the Services that the currently logged-in user has permission to access. --- promgen/views.py | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/promgen/views.py b/promgen/views.py index 901b8e2e0..447371f45 100644 --- a/promgen/views.py +++ b/promgen/views.py @@ -93,22 +93,27 @@ class DatasourceDetail(LoginRequiredMixin, DetailView): class ServiceList(LoginRequiredMixin, ListView): paginate_by = 20 - queryset = models.Service.objects.prefetch_related( - "rule_set", - "rule_set__parent", - "project_set", - "project_set__owner", - "project_set__shard", - "project_set__notifiers", - "project_set__notifiers__owner", - "project_set__notifiers__filter_set", - "project_set__farm", - "project_set__exporter_set", - "owner", - "notifiers", - "notifiers__owner", - "notifiers__filter_set", - ) + + def get_queryset(self): + query_set = models.Service.objects.prefetch_related( + "rule_set", + "rule_set__parent", + "project_set", + "project_set__owner", + "project_set__shard", + "project_set__notifiers", + "project_set__notifiers__owner", + "project_set__notifiers__filter_set", + "project_set__farm", + "project_set__exporter_set", + "owner", + "notifiers", + "notifiers__owner", + "notifiers__filter_set", + ) + + services = permissions.get_accessible_services_for_user(self.request.user) + return query_set.filter(pk__in=services) class HomeList(LoginRequiredMixin, ListView): From 28bc339d81b506a6e47dad9c16b8c55a0d9713d8 Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 22 Apr 2025 10:45:37 +0700 Subject: [PATCH 06/26] Filter the Rule list by the user's permissions We add a filter to the RulesListView to show only the Rules of the Services or the Projects that the currently logged-in user has permission to access. --- promgen/permissions.py | 14 ++++++++++++++ promgen/views.py | 9 +++++++++ 2 files changed, 23 insertions(+) diff --git a/promgen/permissions.py b/promgen/permissions.py index 92c54a119..ae17193e6 100644 --- a/promgen/permissions.py +++ b/promgen/permissions.py @@ -1,6 +1,7 @@ # Copyright (c) 2025 LINE Corporation # These sources are released under the terms of the MIT license: see LICENSE from django.contrib.auth.models import User +from django.db.models import Q from django.utils.itercompat import is_iterable from guardian.shortcuts import get_objects_for_user from rest_framework import permissions @@ -73,3 +74,16 @@ def get_accessible_services_for_user(user: User): accept_global_perms=False, klass=models.Service, ) + + +def get_accessible_projects_for_user(user: User): + services = get_accessible_services_for_user(user) + projects = get_objects_for_user( + user, + ["project_admin", "project_editor", "project_viewer"], + any_perm=True, + use_groups=True, + accept_global_perms=False, + klass=models.Project, + ) + return models.Project.objects.filter(Q(pk__in=projects) | Q(service__in=services)) diff --git a/promgen/views.py b/promgen/views.py index 447371f45..df38ab747 100644 --- a/promgen/views.py +++ b/promgen/views.py @@ -624,9 +624,18 @@ def get_context_data(self, **kwargs): "parent", ) + # If the user is not a superuser, we need to filter the rules by the user's permissions + if not self.request.user.is_superuser: + services = permissions.get_accessible_services_for_user(self.request.user) + service_rules = service_rules.filter(object_id__in=services) + + projects = permissions.get_accessible_projects_for_user(self.request.user) + project_rules = project_rules.filter(object_id__in=projects) + rule_list = list(chain(site_rules, service_rules, project_rules)) page_number = self.request.GET.get("page", 1) context["rule_list"] = Paginator(rule_list, self.paginate_by).page(page_number) + context["page_obj"] = context["rule_list"] return context From 7cd5bb99f8b5476824d086e9e0dd1d03e81dfc92 Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 22 Apr 2025 11:01:16 +0700 Subject: [PATCH 07/26] Filter the Farm list by the user's permissions We add a filter to the FarmListView to show only the Farms that the currently logged-in user has permission to access. --- promgen/views.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/promgen/views.py b/promgen/views.py index df38ab747..c17a25300 100644 --- a/promgen/views.py +++ b/promgen/views.py @@ -520,10 +520,19 @@ def get_context_data(self, **kwargs): class FarmList(LoginRequiredMixin, ListView): paginate_by = 50 - queryset = models.Farm.objects.prefetch_related( - "project", - "host_set", - ) + + def get_queryset(self): + query_set = models.Farm.objects.prefetch_related( + "project", + "host_set", + ) + + # If the user is not a superuser, we need to filter the farms by the user's permissions + if not self.request.user.is_superuser: + projects = permissions.get_accessible_projects_for_user(self.request.user) + query_set = query_set.filter(project__in=projects) + + return query_set class FarmDetail(PromgenGuardianPermissionMixin, DetailView): From d12301dffee9d6a1d8bcc411ad124abde7612958 Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 22 Apr 2025 11:02:33 +0700 Subject: [PATCH 08/26] Filter the URL list by the user's permissions We add a filter to the URLListView to show only the URLs of the Projects that the currently logged-in user has permission to access. --- promgen/views.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/promgen/views.py b/promgen/views.py index c17a25300..619aca98e 100644 --- a/promgen/views.py +++ b/promgen/views.py @@ -846,12 +846,20 @@ def get_success_url(self): class URLList(LoginRequiredMixin, ListView): - queryset = models.URL.objects.prefetch_related( - "project", - "project__service", - "project__shard", - "probe", - ) + def get_queryset(self): + query_set = models.URL.objects.prefetch_related( + "project", + "project__service", + "project__shard", + "probe", + ) + + # If the user is not a superuser, we need to filter the URLs by the user's permissions + if not self.request.user.is_superuser: + projects = permissions.get_accessible_projects_for_user(self.request.user) + query_set = query_set.filter(project__in=projects) + + return query_set class ProjectRegister(PromgenGuardianPermissionMixin, CreateView): From 3f62a05f8453bc37c8b6e2c9a17ad5a814975c5a Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 22 Apr 2025 11:12:06 +0700 Subject: [PATCH 09/26] Filter the Host list by the user's permissions We add a filters the HostListView to show only the Hosts of the Farms that the currently logged-in user has permission to access. --- promgen/views.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/promgen/views.py b/promgen/views.py index 619aca98e..420014aa4 100644 --- a/promgen/views.py +++ b/promgen/views.py @@ -149,11 +149,20 @@ def get_queryset(self): class HostList(LoginRequiredMixin, ListView): - queryset = models.Host.objects.prefetch_related( - "farm", - "farm__project", - "farm__project__service", - ) + def get_queryset(self): + query_set = models.Host.objects.prefetch_related( + "farm", + "farm__project", + "farm__project__service", + ) + + # If the user is not a superuser, we need to filter the hosts by the user's permissions + if not self.request.user.is_superuser: + projects = permissions.get_accessible_projects_for_user(self.request.user) + farms = models.Farm.objects.filter(project__in=projects) + query_set = query_set.filter(farm__in=farms) + + return query_set def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) From 808f6dbb0bb7f0b27ec2b6c886a4d302931ff936 Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 22 Apr 2025 13:56:55 +0700 Subject: [PATCH 10/26] Filter the Alert History by the user's permissions We add a filter to the Alert History page to show only the alerts of the Services or the Projects that the currently logged-in user has permission to access. --- promgen/views.py | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/promgen/views.py b/promgen/views.py index 420014aa4..bb2352281 100644 --- a/promgen/views.py +++ b/promgen/views.py @@ -1283,24 +1283,35 @@ class AlertList(LoginRequiredMixin, ListView): queryset = models.Alert.objects.order_by("-created") def get_queryset(self): + qs = self.queryset search = self.request.GET.get("search") if search: - return self.queryset.filter( + qs = self.queryset.filter( Q(alertlabel__name="Service", alertlabel__value__icontains=search) | Q(alertlabel__name="Project", alertlabel__value__icontains=search) | Q(alertlabel__name="Job", alertlabel__value__icontains=search) ) + else: + for key, value in self.request.GET.items(): + if key in ["page", "search"]: + continue + elif key == "noSent": + qs = qs.filter(sent_count=0) + elif key == "sentError": + qs = qs.exclude(error_count=0) + else: + qs = qs.filter(alertlabel__name=key, alertlabel__value=value) + + # If the user is not a superuser, we need to filter the alerts by the user's permissions + if not self.request.user.is_superuser: + services = permissions.get_accessible_services_for_user(self.request.user) + projects = permissions.get_accessible_projects_for_user(self.request.user) + + qs = qs.filter( + Q(alertlabel__name="Service", alertlabel__value__in=services.values_list("name")) + | Q(alertlabel__name="Project", alertlabel__value__in=projects.values_list("name")) + ) - qs = self.queryset - for key, value in self.request.GET.items(): - if key in ["page", "search"]: - continue - elif key == "noSent": - qs = qs.filter(sent_count=0) - elif key == "sentError": - qs = qs.exclude(error_count=0) - else: - qs = qs.filter(alertlabel__name=key, alertlabel__value=value) return qs From f934a14cf8a05a9117692a93388679f1037b7372 Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 22 Apr 2025 15:47:45 +0700 Subject: [PATCH 11/26] Filter the Edit History by the user's permissions We add a filter to the Edit History page to show only the audit logs of the objects that the currently logged-in user has permission to access. --- promgen/permissions.py | 11 ++++++++++ promgen/views.py | 50 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/promgen/permissions.py b/promgen/permissions.py index ae17193e6..0528ef5fa 100644 --- a/promgen/permissions.py +++ b/promgen/permissions.py @@ -87,3 +87,14 @@ def get_accessible_projects_for_user(user: User): klass=models.Project, ) return models.Project.objects.filter(Q(pk__in=projects) | Q(service__in=services)) + + +def get_accessible_groups_for_user(user: User): + return get_objects_for_user( + user, + ["group_admin", "group_member"], + any_perm=True, + use_groups=False, + accept_global_perms=False, + klass=models.Group, + ) diff --git a/promgen/views.py b/promgen/views.py index bb2352281..c7734baa4 100644 --- a/promgen/views.py +++ b/promgen/views.py @@ -264,6 +264,56 @@ def get_queryset(self): if "user" in self.request.GET: queryset = queryset.filter(user_id=self.request.GET["user"]) + # If the user is not a superuser, we need to filter the audits by the user's permissions + if not self.request.user.is_superuser: + # Get all the services that the user has access to + services = permissions.get_accessible_services_for_user(self.request.user) + + # Get all the projects that the user has access to + projects = permissions.get_accessible_projects_for_user(self.request.user) + + # Get all the farm that the user has access to + farms = models.Farm.objects.filter(project__in=projects) + + # Get all the groups that the user has access to + groups = permissions.get_accessible_groups_for_user(self.request.user) + + # Filter the queryset by the user's permissions + queryset = queryset.filter( + Q( + content_type__model="service", + content_type__app_label="promgen", + object_id__in=services, + ) + | Q( + content_type__model="project", + content_type__app_label="promgen", + object_id__in=projects, + ) + | Q( + content_type__model="farm", + content_type__app_label="promgen", + object_id__in=farms, + ) + | Q( + parent_content_type_id=ContentType.objects.get_for_model(models.Service).id, + parent_object_id__in=services, + ) + | Q( + parent_content_type_id=ContentType.objects.get_for_model(models.Project).id, + parent_object_id__in=projects, + ) + | Q( + parent_content_type_id=ContentType.objects.get_for_model(models.Farm).id, + parent_object_id__in=farms, + ) + | Q( + content_type__model="group", + content_type__app_label="promgen", + object_id__in=groups, + ) + ) + return queryset paginate_by = 50 From 8c525812730047e6705d90d8036714c63d1b1463 Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 22 Apr 2025 16:57:30 +0700 Subject: [PATCH 12/26] Filter the Search result by the user's permissions We add a filter to the SearchView to show only the objects of the search result that the currently logged-in user has permission to access. --- promgen/views.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/promgen/views.py b/promgen/views.py index c7734baa4..e30ed6bdd 100644 --- a/promgen/views.py +++ b/promgen/views.py @@ -1535,6 +1535,38 @@ def get(self, request): logger.info("filtering %s by %s", target, filters) qs = qs.filter(filters) + + # If the user is not a superuser, we need to filter the result by the user's permissions + if not self.request.user.is_superuser: + services = permissions.get_accessible_services_for_user(self.request.user) + projects = permissions.get_accessible_projects_for_user(self.request.user) + farms = models.Farm.objects.filter(project__in=projects) + groups = permissions.get_accessible_groups_for_user(self.request.user) + + if obj["model"] == models.Service: + qs = qs.filter(pk__in=services) + elif obj["model"] == models.Project: + qs = qs.filter(pk__in=projects) + elif obj["model"] == models.Farm: + qs = qs.filter(pk__in=farms) + elif obj["model"] == models.Host: + qs = qs.filter(farm__in=farms) + elif obj["model"] == models.Group: + qs = qs.filter(pk__in=groups) + elif obj["model"] == models.Rule: + qs = qs.filter( + Q( + content_type__model="service", + content_type__app_label="promgen", + object_id__in=services, + ) + | Q( + content_type__model="project", + content_type__app_label="promgen", + object_id__in=projects, + ) + ) + try: page_number = query_dict.get("page", 1) page_target = Paginator(qs, self.paginate_by).page(page_number) From b950c36298c3c336b064a0aac06eb878ba629cc2 Mon Sep 17 00:00:00 2001 From: hoang Date: Wed, 23 Apr 2025 09:36:04 +0700 Subject: [PATCH 13/26] Filter the Project list of the Datasource by the user's permissions We add filters to the DatasourceListView and DatasourceDetailView to show only the projects that the currently logged-in user has permission to access. --- promgen/views.py | 72 ++++++++++++++++++++++++++++-------------------- 1 file changed, 42 insertions(+), 30 deletions(-) diff --git a/promgen/views.py b/promgen/views.py index e30ed6bdd..6277fc8c1 100644 --- a/promgen/views.py +++ b/promgen/views.py @@ -18,7 +18,7 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.core.paginator import EmptyPage, Paginator -from django.db.models import Count, Q +from django.db.models import Count, Prefetch, Q from django.db.utils import IntegrityError from django.http import HttpResponse, HttpResponseRedirect, JsonResponse from django.shortcuts import get_object_or_404, redirect, render @@ -57,38 +57,50 @@ class DatasourceList(LoginRequiredMixin, ListView): - queryset = models.Shard.objects.prefetch_related( - "project_set__service", - "project_set__service__owner", - "project_set__service__notifiers", - "project_set__service__notifiers__owner", - "project_set__service__rule_set", - "project_set", - "project_set__owner", - "project_set__farm", - "project_set__exporter_set", - "project_set__notifiers", - "project_set__notifiers__owner", - "prometheus_set", - ).annotate(num_projects=Count("project")) + def get_queryset(self): + projects = models.Project.objects.all() + # If the user is not a superuser, we need to filter the shards by the user's permissions + if not self.request.user.is_superuser: + projects = permissions.get_accessible_projects_for_user(self.request.user) + + return models.Shard.objects.prefetch_related( + Prefetch("project_set", queryset=projects), + "project_set__service", + "project_set__service__owner", + "project_set__service__notifiers", + "project_set__service__notifiers__owner", + "project_set__service__rule_set", + "project_set__owner", + "project_set__farm", + "project_set__exporter_set", + "project_set__notifiers", + "project_set__notifiers__owner", + "prometheus_set", + ).annotate(num_projects=Count("project")) class DatasourceDetail(LoginRequiredMixin, DetailView): - queryset = models.Shard.objects.prefetch_related( - "project_set__service", - "project_set__service__owner", - "project_set__service__notifiers", - "project_set__service__notifiers__owner", - "project_set__service__notifiers__filter_set", - "project_set__service__rule_set", - "project_set", - "project_set__owner", - "project_set__farm", - "project_set__exporter_set", - "project_set__notifiers", - "project_set__notifiers__owner", - "project_set__notifiers__filter_set", - ) + def get_queryset(self): + projects = models.Project.objects.all() + # If the user is not a superuser, we need to filter the shards by the user's permissions + if not self.request.user.is_superuser: + projects = permissions.get_accessible_projects_for_user(self.request.user) + + return models.Shard.objects.prefetch_related( + Prefetch("project_set", queryset=projects), + "project_set__service", + "project_set__service__owner", + "project_set__service__notifiers", + "project_set__service__notifiers__owner", + "project_set__service__notifiers__filter_set", + "project_set__service__rule_set", + "project_set__owner", + "project_set__farm", + "project_set__exporter_set", + "project_set__notifiers", + "project_set__notifiers__owner", + "project_set__notifiers__filter_set", + ) class ServiceList(LoginRequiredMixin, ListView): From 4730f063492331d7d84b48e4edb2963055c9f40a Mon Sep 17 00:00:00 2001 From: hoang Date: Wed, 23 Apr 2025 15:35:04 +0700 Subject: [PATCH 14/26] Filter the Host page by the user's permissions We add filters to the HostDetailView to show only the hosts and their related objects that the currently logged-in user has permission to access. --- promgen/views.py | 65 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 49 insertions(+), 16 deletions(-) diff --git a/promgen/views.py b/promgen/views.py index 6277fc8c1..c78f20efa 100644 --- a/promgen/views.py +++ b/promgen/views.py @@ -196,48 +196,81 @@ def get(self, request, slug): context = {} context["slug"] = self.kwargs["slug"] - context["host_list"] = models.Host.objects.filter( - name__icontains=self.kwargs["slug"] - ).prefetch_related("farm") + hosts = models.Host.objects.filter(name__icontains=self.kwargs["slug"]).prefetch_related( + "farm" + ) + + # If the user is not a superuser, we need to filter the hosts by the user's permissions + if not self.request.user.is_superuser: + projects = permissions.get_accessible_projects_for_user(self.request.user) + farms = models.Farm.objects.filter(project__in=projects) + hosts = hosts.filter(farm__in=farms) + + context["host_list"] = hosts if not context["host_list"]: return render(request, "promgen/host_404.html", context, status=404) - context["farm_list"] = models.Farm.objects.filter( + farms = models.Farm.objects.filter( id__in=context["host_list"].values_list("farm_id", flat=True) ) - context["project_list"] = models.Project.objects.filter( - id__in=context["farm_list"].values_list("project__id", flat=True) + projects = models.Project.objects.filter( + id__in=farms.values_list("project__id", flat=True) ).prefetch_related("notifiers", "rule_set") - context["exporter_list"] = models.Exporter.objects.filter( - project_id__in=context["project_list"].values_list("id", flat=True) + exporters = models.Exporter.objects.filter( + project_id__in=projects.values_list("id", flat=True) ).prefetch_related("project", "project__service") - context["service_list"] = models.Service.objects.filter( - id__in=context["project_list"].values_list("service__id", flat=True) + services = models.Service.objects.filter( + id__in=projects.values_list("service__id", flat=True) ).prefetch_related("notifiers", "rule_set") - context["rule_list"] = ( + rules = ( models.Rule.objects.filter( - Q(id__in=context["project_list"].values_list("rule_set__id")) - | Q(id__in=context["service_list"].values_list("rule_set__id")) + Q(id__in=projects.values_list("rule_set__id")) + | Q(id__in=services.values_list("rule_set__id")) | Q(id__in=models.Site.objects.get_current().rule_set.values_list("id")) ) .select_related("content_type") .prefetch_related("content_object") ) - context["notifier_list"] = ( + notifiers = ( models.Sender.objects.filter( - Q(id__in=context["project_list"].values_list("notifiers__id")) - | Q(id__in=context["service_list"].values_list("notifiers__id")) + Q(id__in=projects.values_list("notifiers__id")) + | Q(id__in=services.values_list("notifiers__id")) ) .select_related("content_type") .prefetch_related("content_object") ) + # If the user is not a superuser, we need to filter other objects by the user's permissions + if not self.request.user.is_superuser: + accessible_services = permissions.get_accessible_services_for_user(self.request.user) + accessible_projects = permissions.get_accessible_projects_for_user(self.request.user) + + projects = projects.filter(pk__in=accessible_projects) + exporters = exporters.filter(project__in=accessible_projects) + services = services.filter(pk__in=accessible_services) + rules = rules.filter( + Q(content_type__model="service", object_id__in=accessible_services) + | Q(content_type__model="project", object_id__in=accessible_projects) + | Q(id__in=models.Site.objects.get_current().rule_set.values_list("id")) + ) + notifiers = notifiers.filter( + Q(content_type__model="service", object_id__in=accessible_services) + | Q(content_type__model="project", object_id__in=accessible_projects) + ) + + context["farm_list"] = farms + context["project_list"] = projects + context["exporter_list"] = exporters + context["service_list"] = services + context["rule_list"] = rules + context["notifier_list"] = notifiers + return render(request, "promgen/host_detail.html", context) From 284bd562768041a143a6415896527fbdcd4c38af Mon Sep 17 00:00:00 2001 From: hoang Date: Wed, 23 Apr 2025 23:28:52 +0700 Subject: [PATCH 15/26] Filter the Proxy's Alerts and Proxy's Silences by the user's permissions We add filters to the Proxy's APIs response to return only the alerts and the silences of the services or the projects that the currently logged-in user has permission to access. --- promgen/proxy.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/promgen/proxy.py b/promgen/proxy.py index c958e468a..af9680778 100644 --- a/promgen/proxy.py +++ b/promgen/proxy.py @@ -16,6 +16,7 @@ from rest_framework.views import APIView from promgen import forms, models, prometheus, serializers, util +from promgen import permissions as promgen_permissions logger = logging.getLogger(__name__) @@ -164,6 +165,22 @@ def get(self, request): logger.error("Error connecting to %s", url) return JsonResponse({}, status=HTTPStatus.INTERNAL_SERVER_ERROR) else: + # Filter the alerts based on the user's permissions + if not self.request.user.is_superuser: + services = promgen_permissions.get_accessible_services_for_user(self.request.user) + projects = promgen_permissions.get_accessible_projects_for_user(self.request.user) + + accessible_projects = projects.values_list("name", flat=True) + accessible_services = services.values_list("name", flat=True) + + filtered_response = [ + alert + for alert in response.json() + if alert.get("labels", {}).get("service") in accessible_services + or alert.get("labels", {}).get("project") in accessible_projects + ] + return HttpResponse(json.dumps(filtered_response), content_type="application/json") + # If the user is a superuser, return all alerts return HttpResponse(response.content, content_type="application/json") @@ -176,6 +193,31 @@ def get(self, request): logger.error("Error connecting to %s", url) return JsonResponse({}, status=HTTPStatus.INTERNAL_SERVER_ERROR) else: + # Filter the silences based on the user's permissions + if not self.request.user.is_superuser: + services = promgen_permissions.get_accessible_services_for_user(self.request.user) + projects = promgen_permissions.get_accessible_projects_for_user(self.request.user) + + accessible_projects = projects.values_list("name", flat=True) + accessible_services = services.values_list("name", flat=True) + + filtered_response = [ + silence + for silence in response.json() + if any( + ( + matcher.get("name") == "service" + and matcher.get("value") in accessible_services + ) + or ( + matcher.get("name") == "project" + and matcher.get("value") in accessible_projects + ) + for matcher in silence.get("matchers", []) + ) + ] + return HttpResponse(json.dumps(filtered_response), content_type="application/json") + return HttpResponse(response.content, content_type="application/json") def post(self, request): From 125b7a3b50728405bde94e2eac47aaf9e5e53bc6 Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 13 May 2025 09:53:21 +0700 Subject: [PATCH 16/26] Filter the Service retrieve API by the user's permissions We add a filters to the responses of the Service retrieve APIs to show only the data that the authenticated user has permission to access. --- promgen/rest.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/promgen/rest.py b/promgen/rest.py index 45ed8ea6b..e87d3dcff 100644 --- a/promgen/rest.py +++ b/promgen/rest.py @@ -8,7 +8,7 @@ from rest_framework.response import Response from rest_framework.views import APIView -from promgen import filters, models, prometheus, renderers, serializers, tasks, util +from promgen import filters, models, permissions, prometheus, renderers, serializers, tasks, util from promgen.permissions import PromgenModelPermissions @@ -113,6 +113,12 @@ class ServiceViewSet(NotifierMixin, RuleMixin, viewsets.ModelViewSet): lookup_value_regex = "[^/]+" lookup_field = "name" + def get_queryset(self): + query_set = self.queryset + return query_set.filter( + pk__in=permissions.get_accessible_services_for_user(self.request.user) + ) + @action(detail=True, methods=["get"]) def projects(self, request, name): service = self.get_object() From 5912437a52d253cca90bc9c6237ae5d7bd57701d Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 13 May 2025 09:56:09 +0700 Subject: [PATCH 17/26] Filter the Project retrieve API by the user's permissions We add a filters to the responses of the Project retrieve APIs to show only the data that the authenticated user has permission to access. --- promgen/rest.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/promgen/rest.py b/promgen/rest.py index e87d3dcff..8522a66ab 100644 --- a/promgen/rest.py +++ b/promgen/rest.py @@ -139,6 +139,12 @@ class ProjectViewSet(NotifierMixin, RuleMixin, viewsets.ModelViewSet): lookup_value_regex = "[^/]+" lookup_field = "name" + def get_queryset(self): + query_set = self.queryset + return query_set.filter( + pk__in=permissions.get_accessible_projects_for_user(self.request.user) + ) + @action(detail=True, methods=["get"]) def targets(self, request, name): return HttpResponse( From f052160c9563d127b52cd3beb298e91f6258c144 Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 13 May 2025 09:57:31 +0700 Subject: [PATCH 18/26] Filter the Farm retrieve API by the user's permissions We add a filters to the responses of the Farm retrieve APIs to show only the data that the authenticated user has permission to access. --- promgen/rest.py | 8 ++++++++ promgen/tests/test_rest.py | 13 +++++++++++++ 2 files changed, 21 insertions(+) diff --git a/promgen/rest.py b/promgen/rest.py index 8522a66ab..37413fdb6 100644 --- a/promgen/rest.py +++ b/promgen/rest.py @@ -160,6 +160,14 @@ class FarmViewSet(viewsets.ModelViewSet): lookup_value_regex = "[^/]+" lookup_field = "id" + def get_queryset(self): + query_set = self.queryset + # If the user is not a superuser, we need to filter the farms by the user's permissions + if not self.request.user.is_superuser: + projects = permissions.get_accessible_projects_for_user(self.request.user) + query_set = query_set.filter(project__in=projects) + return query_set + def retrieve(self, request, id): farm = self.get_object() farm_data = self.get_serializer(farm).data diff --git a/promgen/tests/test_rest.py b/promgen/tests/test_rest.py index 9335bae97..656051989 100644 --- a/promgen/tests/test_rest.py +++ b/promgen/tests/test_rest.py @@ -5,6 +5,7 @@ from django.contrib.auth.models import Permission from django.test import override_settings from django.urls import reverse +from guardian.shortcuts import assign_perm from promgen import models, rest, tests @@ -37,6 +38,18 @@ def test_alert(self): def test_retrieve_farm(self): expected = tests.Data("examples", "rest.farm.json").json() + # Check retrieving all farms without assigning permissions return empty list + response = self.client.get(reverse("api:farm-list")) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), []) + + # Check retrieving a specific farm without assigning permissions return 404 Not Found + response = self.client.get(reverse("api:farm-detail", args=[1])) + self.assertEqual(response.status_code, 404) + + # Assigning permissions to the user + assign_perm("project_viewer", self.user, models.Project.objects.get(id=1)) + # Check retrieving all farms response = self.client.get(reverse("api:farm-list")) self.assertEqual(response.status_code, 200) From 188efab464d5ce50917444bd3298d7fc5b35c232 Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 13 May 2025 10:33:13 +0700 Subject: [PATCH 19/26] Filter the Export Rules API by the user's permissions We add a filters to the responses of the Export Rules API to show only the data that the authenticated user has permission to access. --- promgen/rest.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/promgen/rest.py b/promgen/rest.py index 37413fdb6..c4b98328b 100644 --- a/promgen/rest.py +++ b/promgen/rest.py @@ -1,5 +1,6 @@ # Copyright (c) 2019 LINE Corporation # These sources are released under the terms of the MIT license: see LICENSE +from itertools import chain from django.http import HttpResponse from requests.exceptions import HTTPError @@ -30,7 +31,25 @@ def post(self, request, *args, **kwargs): class AllViewSet(viewsets.ViewSet): @action(detail=False, methods=["get"], renderer_classes=[renderers.RuleRenderer]) def rules(self, request): - rules = models.Rule.objects.filter(enabled=True) + site_rules = models.Rule.objects.filter( + content_type__model="site", content_type__app_label="promgen", enabled=True + ) + service_rules = models.Rule.objects.filter( + content_type__model="service", content_type__app_label="promgen", enabled=True + ) + project_rules = models.Rule.objects.filter( + content_type__model="project", content_type__app_label="promgen", enabled=True + ) + + # If the user is not a superuser, we need to filter the rules by the user's permissions + if not self.request.user.is_superuser: + services = permissions.get_accessible_services_for_user(self.request.user) + service_rules = service_rules.filter(object_id__in=services) + + projects = permissions.get_accessible_projects_for_user(self.request.user) + project_rules = project_rules.filter(object_id__in=projects) + + rules = list(chain(site_rules, service_rules, project_rules)) return Response( serializers.AlertRuleSerializer(rules, many=True).data, headers={"Content-Disposition": "attachment; filename=alert.rule.yml"}, From 9a594cd2ea87702e9fed018e1553fc6eb75095e1 Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 13 May 2025 13:42:04 +0700 Subject: [PATCH 20/26] Filter the Export Targets API by the user's permissions We add a filters to the responses of the Export Targets API to show only the data that the authenticated user has permission to access. --- promgen/prometheus.py | 11 ++++++++--- promgen/rest.py | 13 ++++++++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/promgen/prometheus.py b/promgen/prometheus.py index 7b30dc380..881e0db59 100644 --- a/promgen/prometheus.py +++ b/promgen/prometheus.py @@ -98,7 +98,7 @@ def render_urls(): return json.dumps(data, indent=2, sort_keys=True) -def render_config(service=None, project=None): +def render_config(service=None, project=None, services=None, projects=None, farms=None): data = [] for exporter in models.Exporter.objects.prefetch_related( "project__farm__host_set", @@ -113,6 +113,10 @@ def render_config(service=None, project=None): continue if project and exporter.project.name != project.name: continue + if services is not None and exporter.project.service not in services: + continue + if projects is not None and exporter.project not in projects: + continue if not exporter.enabled: continue @@ -129,8 +133,9 @@ def render_config(service=None, project=None): labels["__metrics_path__"] = exporter.path hosts = [] - for host in exporter.project.farm.host_set.all(): - hosts.append(f"{host.name}:{exporter.port}") + if farms is None or exporter.project.farm in farms: + for host in exporter.project.farm.host_set.all(): + hosts.append(f"{host.name}:{exporter.port}") data.append({"labels": labels, "targets": hosts}) return json.dumps(data, indent=2, sort_keys=True) diff --git a/promgen/rest.py b/promgen/rest.py index c4b98328b..25df91c08 100644 --- a/promgen/rest.py +++ b/promgen/rest.py @@ -57,8 +57,19 @@ def rules(self, request): @action(detail=False, methods=["get"], renderer_classes=[renderers.renderers.JSONRenderer]) def targets(self, request): + if self.request.user.is_superuser: + return HttpResponse( + prometheus.render_config(), + content_type="application/json", + ) + + # if the user is not a superuser, we need to filter the targets by the user's permissions + services = permissions.get_accessible_services_for_user(self.request.user) + projects = permissions.get_accessible_projects_for_user(self.request.user) + farms = models.Farm.objects.filter(project__in=projects) + return HttpResponse( - prometheus.render_config(), + prometheus.render_config(services=services, projects=projects, farms=farms), content_type="application/json", ) From 4f57cccaf1eed52d8a810515dc8dfeeadee0f313 Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 13 May 2025 13:56:52 +0700 Subject: [PATCH 21/26] Filter the Export URLs API by the user's permissions We add a filters to the responses of the Export URLs API to show only the data that the authenticated user has permission to access. --- promgen/prometheus.py | 10 +++++++--- promgen/rest.py | 11 ++++++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/promgen/prometheus.py b/promgen/prometheus.py index 881e0db59..8918adb01 100644 --- a/promgen/prometheus.py +++ b/promgen/prometheus.py @@ -65,14 +65,18 @@ def render_rules(rules=None): return renderers.RuleRenderer().render(serializers.AlertRuleSerializer(rules, many=True).data) -def render_urls(): +def render_urls(projects=None): urls = collections.defaultdict(list) - for url in models.URL.objects.prefetch_related( + url_queryset = models.URL.objects.prefetch_related( "project__service", "project__shard", "project", - ): + ) + if projects is not None: + url_queryset = url_queryset.filter(project__in=projects) + + for url in url_queryset: urls[ ( url.project.name, diff --git a/promgen/rest.py b/promgen/rest.py index 25df91c08..3a76d0921 100644 --- a/promgen/rest.py +++ b/promgen/rest.py @@ -75,8 +75,17 @@ def targets(self, request): @action(detail=False, methods=["get"], renderer_classes=[renderers.renderers.JSONRenderer]) def urls(self, request): + if self.request.user.is_superuser: + return HttpResponse( + prometheus.render_urls(), + content_type="application/json", + ) + + # if the user is not a superuser, we need to filter the URLs by the user's permissions + projects = permissions.get_accessible_projects_for_user(self.request.user) + return HttpResponse( - prometheus.render_urls(), + prometheus.render_urls(projects=projects), content_type="application/json", ) From 076cc6e7ed4a0cf85d8189ff37fd097fc620573a Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 13 May 2025 15:52:16 +0700 Subject: [PATCH 22/26] Check user's permissions when silencing an alert We implement a permission check logic at the Prometheus proxy API to ensure that users must have the Admin or Editor role on the service/project when they want to silence an alert for that service/project. --- promgen/proxy.py | 42 ++++++++++++++++++++ promgen/tests/examples/silence.duration.json | 2 +- promgen/tests/examples/silence.range.json | 2 +- promgen/tests/test_silence.py | 2 +- 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/promgen/proxy.py b/promgen/proxy.py index af9680778..0cecc7c88 100644 --- a/promgen/proxy.py +++ b/promgen/proxy.py @@ -238,6 +238,48 @@ def post(self, request): status=HTTPStatus.UNPROCESSABLE_ENTITY, ) + # Check if the user has permission to silence the alert + if not request.user.is_superuser: + if "project" not in body["labels"] and "service" not in body["labels"]: + return JsonResponse( + { + "messages": [ + { + "class": "alert alert-warning", + "message": "You must specify either a project or service label", + } + ] + }, + status=HTTPStatus.UNPROCESSABLE_ENTITY, + ) + + permission_denied_response = JsonResponse( + { + "messages": [ + { + "class": "alert alert-danger", + "message": "You do not have permission to silence this alert", + } + ] + }, + status=HTTPStatus.FORBIDDEN, + ) + if "project" in body["labels"]: + project = models.Project.objects.get(name=body["labels"]["project"]) + if ( + not request.user.has_perm("project_admin", project) + and not request.user.has_perm("project_editor", project) + and not request.user.has_perm("service_admin", project.service) + and not request.user.has_perm("service_editor", project.service) + ): + return permission_denied_response + elif "service" in body["labels"]: + service = models.Service.objects.get(name=body["labels"]["service"]) + if not request.user.has_perm( + "service_admin", service + ) and not request.user.has_perm("service_editor", service): + return permission_denied_response + try: response = prometheus.silence(**form.cleaned_data) except requests.HTTPError as e: diff --git a/promgen/tests/examples/silence.duration.json b/promgen/tests/examples/silence.duration.json index f468c9c9f..3e4e452ee 100644 --- a/promgen/tests/examples/silence.duration.json +++ b/promgen/tests/examples/silence.duration.json @@ -1,5 +1,5 @@ { - "createdBy": "demo@example.com", + "createdBy": "admin@example.com", "matchers": [{ "value": "example.com:[0-9]*", "isRegex": true, diff --git a/promgen/tests/examples/silence.range.json b/promgen/tests/examples/silence.range.json index 7277afc58..b9bc7daf2 100644 --- a/promgen/tests/examples/silence.range.json +++ b/promgen/tests/examples/silence.range.json @@ -1,5 +1,5 @@ { - "createdBy": "demo@example.com", + "createdBy": "admin@example.com", "matchers": [{ "value": "example.com:[0-9]*", "isRegex": true, diff --git a/promgen/tests/test_silence.py b/promgen/tests/test_silence.py index fa94769a5..7ae8aa1b6 100644 --- a/promgen/tests/test_silence.py +++ b/promgen/tests/test_silence.py @@ -21,7 +21,7 @@ class SilenceTest(tests.PromgenTest): fixtures = ["testcases.yaml", "extras.yaml"] def setUp(self): - self.user = self.force_login(username="demo") + self.user = self.force_login(username="admin") @override_settings(PROMGEN=TEST_SETTINGS) @mock.patch("promgen.util.post") From 80f2c89ffa4c0043107ab9925e5cc0af9ec78902 Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 13 May 2025 16:21:48 +0700 Subject: [PATCH 23/26] Check user's permissions when expiring a silence We implement a permission check logic at the Prometheus proxy API to ensure that users must have the Admin or Editor role on the service/project when they want to delete a silence for that service/project. --- promgen/proxy.py | 57 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/promgen/proxy.py b/promgen/proxy.py index 0cecc7c88..d1544c3b1 100644 --- a/promgen/proxy.py +++ b/promgen/proxy.py @@ -344,6 +344,63 @@ def post(self, request, *args, **kwargs): class ProxyDeleteSilence(View): def delete(self, request, silence_id): url = urljoin(util.setting("alertmanager:url"), f"/api/v2/silence/{silence_id}") + # First, check if the silence exists + response = util.get(url) + if response.status_code != 200: + return HttpResponse( + response.text, status=response.status_code, content_type="application/json" + ) + + # Check if the user has permission to delete the silence + if not request.user.is_superuser: + silence = response.json() + project = None + service = None + for matcher in silence.get("matchers", []): + if matcher.get("name") == "project": + project = matcher.get("value") + if matcher.get("name") == "service": + service = matcher.get("value") + if project is None and service is None: + return JsonResponse( + { + "messages": [ + { + "class": "alert alert-warning", + "message": "Silence must have either a project or service matcher", + } + ] + }, + status=HTTPStatus.UNPROCESSABLE_ENTITY, + ) + permission_denied_response = JsonResponse( + { + "messages": [ + { + "class": "alert alert-danger", + "message": "You do not have permission to delete this silence", + } + ] + }, + status=HTTPStatus.FORBIDDEN, + ) + if project: + project = models.Project.objects.get(name=project) + if ( + not request.user.has_perm("project_admin", project) + and not request.user.has_perm("project_editor", project) + and not request.user.has_perm("service_admin", project.service) + and not request.user.has_perm("service_editor", project.service) + ): + return permission_denied_response + elif service: + service = models.Service.objects.get(name=service) + if not request.user.has_perm( + "service_admin", service + ) and not request.user.has_perm("service_editor", service): + return permission_denied_response + + # Delete the silence response = util.delete(url) return HttpResponse( response.text, status=response.status_code, content_type="application/json" From b62cd1f8a001aa824d4142700980de80315d40ff Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 30 Sep 2025 15:29:25 +0700 Subject: [PATCH 24/26] Filter the Group list by user's permissions We add a filters the GroupListView to show only the Groups that the currently logged-in user has permission to access. --- promgen/views.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/promgen/views.py b/promgen/views.py index c78f20efa..cb972f585 100644 --- a/promgen/views.py +++ b/promgen/views.py @@ -1991,7 +1991,9 @@ def build_warning_message(self, projects, previous_owner): class GroupList(LoginRequiredMixin, ListView): paginate_by = 20 - queryset = models.Group.objects.order_by("name") + + def get_queryset(self): + return permissions.get_accessible_groups_for_user(self.request.user).order_by("name") class GroupDetail(PromgenGuardianPermissionMixin, DetailView): From d011aefc0f09f1767561b49a6048ea4388afe130 Mon Sep 17 00:00:00 2001 From: hoang Date: Mon, 6 Oct 2025 15:47:48 +0700 Subject: [PATCH 25/26] Remove the explanatory message of the permission block After releasing the per-object permission authorization model, the logic for checking per-object permissions has been enabled. Therefore, the message explaining the previous transitional release has become redundant. As a result, we have removed this message from the UI and the locale file. --- promgen/locale/ja/LC_MESSAGES/django.mo | Bin 7238 -> 6837 bytes promgen/locale/ja/LC_MESSAGES/django.po | 6 ------ promgen/templates/promgen/project_detail.html | 3 --- promgen/templates/promgen/service_detail.html | 3 --- 4 files changed, 12 deletions(-) diff --git a/promgen/locale/ja/LC_MESSAGES/django.mo b/promgen/locale/ja/LC_MESSAGES/django.mo index 628151965891554eae1b351c701715ae8dc32862..554ff0f60d6adb72600b71a41ff8e3983dfbefe3 100644 GIT binary patch delta 1133 zcmX}sUr19?9KiA4)w@|bXXA|5`Xdn={eeMnd#R|= zLtN%VtcM_asF!H?5K&}8L_QTkxG2K6VD!>MM1Sb}bB8XQpYuCs=bqpBorA9iX8WAa zZFW(3hWLi~2I@o(qtE6KPu3&SfH{m|9(8;cN3g{!vKJ3x0%tIeOSl>%Or|Nr*o1NP zW2zQAk}(}w>|(&dFy6%gzBGQoR^lq^i~pK9u%h<6O&FvRS3c z$gAWYG9-^pJl`tLa>rXb1{qi~hT24Wh^KG^UP3*B5{B`Ki7RIO2X3W5*j`&`6m_8~ z)C5kV&OeLmuwdf*?bKgOJx_<0>=&l7frHW)W>9xhL~@gds5^OvTG9p6UqM~4YR3PV zxQWSBSwg61-fJAk6U65n8vAKf@B)T8;7Oc8P3$Y`5p)G>nfIb5FoJpqCh-=gaSp$k z@kbrC9Y04+>?5YIiu(THwOnNEDbgs=_=0-*&QKqY{{N#FbGI9{JFP)CZf~~4&DjU6 tes|6eT07iDyVIjrYQX(zA8+18OD~rGpBO5i_ISPJps(In{@#(S{|Cx6aasTX delta 1525 zcmZ|PPi$009Ki8e-B$Tmb*)w@!VnM-HfW;!DYOLwBx-}00x<n4=0m{jo4#88xC0}F>LVvLc*c+e;Z^k9OMZROC5XH5M5wl5^c__Dj7ncw{T zy_v~gTX%D#|9dKVM)7RryG*_hDAg?{`QaI=Q|ciMa5ENB+P}jcxT0REZP<&QcphKH zzwilcV=zV4g^REo8_>+xeN~79L%5QLQ>gJWHsa5*v-lYGdngk(JUm}tjnZ#DHen|& zz%(w#ev|}Gpe!83@sV2HSL$O5t7w=+8Tb=!$KP-{E+;5E(~gqBeq4ec%7iE4co83^ zel~8OjO$;ZB=Q4FqPKB1&S59(tJFfJHsK+B1;=m${(-V1OW8MGTD9SmxC0rh4xpTp z!3jKqM=`}}=P*J-N|UW5-hz^F2X^2t^jj#DD7=Kz*o$-c0(Q}Rb3&;QC4n1{DD?*3 z#6fK0b@$+Dd=sm99NSrK7_Xun)t1!!5$r>zRtJ%;>PSlYwZO?ZP{dXmK8n4C&r)xA zoQsApqa4Lv)OakekH+m&_#*KiQ5KT@mW3Kn?!XF^@oR7?Zf|7&C2)vU}(havA?duE70A{sTJ7&m@}@o64=p=MwA7PI7l*WBGEjDY2paeX_Z3 z3q4xPca!NQ?Udwxwej6r>8`7<_uag9bRp+t@@~P+W*krFjAxv@S*v#*HyQ07)`Qsr z?c|JhdOV{GS?%QW?pqn1%X%g+^?uXeV{-WxUFb9VsLA!abjW6Oy3eHFW_lT#E(|!H zH>i8vyiC)(R`2aIgZiCSuIK3R(6s--e*jxQJtY7D diff --git a/promgen/locale/ja/LC_MESSAGES/django.po b/promgen/locale/ja/LC_MESSAGES/django.po index 53b52153c..35e236ab5 100644 --- a/promgen/locale/ja/LC_MESSAGES/django.po +++ b/promgen/locale/ja/LC_MESSAGES/django.po @@ -185,12 +185,6 @@ msgstr "Actions" msgid "No search parameters provided." msgstr "検索フレーズが指定されていません" -#: templates/promgen/service_detail.html:93 -#: templates/promgen/project_detail.html:101 -#: templates/promgen/farm_detail.html:71 -msgid "This is a transitional release. Even if you are able to assign roles to members, the permission checks are actually disabled. They will be enabled in the next release." -msgstr "このリリースは移行用のリリースです。ロールをメンバーに割り当てても、権限のチェックは行われません。次のリリースで権限のチェックが有効化されます。" - #: templates/promgen/permission_row.html:28 msgid "Delete all permissions for user?" msgstr "ユーザーから全ての権限を削除しますか?" diff --git a/promgen/templates/promgen/project_detail.html b/promgen/templates/promgen/project_detail.html index 4fb2c733e..4ace77a62 100644 --- a/promgen/templates/promgen/project_detail.html +++ b/promgen/templates/promgen/project_detail.html @@ -97,9 +97,6 @@

- {% include "promgen/permission_block.html" with object=project %}
diff --git a/promgen/templates/promgen/service_detail.html b/promgen/templates/promgen/service_detail.html index 06e7a2d2a..cae8e467c 100644 --- a/promgen/templates/promgen/service_detail.html +++ b/promgen/templates/promgen/service_detail.html @@ -89,9 +89,6 @@

Service: {{ service.name }}

- {% include "promgen/permission_block.html" with object=service %}
From 8193aace6d4cf15337a9c63e58c0ae59da1a6652 Mon Sep 17 00:00:00 2001 From: hoang Date: Wed, 5 Nov 2025 16:55:44 +0700 Subject: [PATCH 26/26] Add check per-object permissions for ExporterScrape view Currently, a Django View class is used for the Exporter Scrape feature, but it returns JSON instead of HTML. This makes it difficult to use PromgenGuardianPermissionMixin for permission checks because this View class is used as an API rather than a typical View class. However, if we don't check permissions on ExporterScrape, a security vulnerability could arise in the future, allowing a user to view the list of hosts for a project they don't have access to. Therefore, it is necessary to check permissions on ExporterScrape. The ideal solution for permission checking would be to replace this View class with a true REST API and implement permission checks for the API. However, this solution requires a significant amount of code changes. We also plan to rewrite a completely new version of Promgen's APIs soon. Therefore, to address the permission check issue on ExporterScrape, we have chosen a simpler solution by manually checking permissions directly in the "post" function of this class. --- promgen/views.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/promgen/views.py b/promgen/views.py index cb972f585..e6dac2f71 100644 --- a/promgen/views.py +++ b/promgen/views.py @@ -877,6 +877,18 @@ class ExporterScrape(LoginRequiredMixin, View): def post(self, request, pk): # Lookup our farm for testing project = get_object_or_404(models.Project, pk=pk) + + # Check per-object permissions + has_perm = any( + self.request.user.has_perm(perm, project) + for perm in ["project_viewer", "project_editor", "project_admin"] + ) or any( + self.request.user.has_perm(perm, project.service) + for perm in ["service_viewer", "service_editor", "service_admin"] + ) + if not has_perm: + return JsonResponse({"error": "You do not have permission to perform this action."}) + farm = getattr(project, "farm", None) # So we have a mutable dictionary