From 4d58485552d5406bee53f092dc5a36b0041b7672 Mon Sep 17 00:00:00 2001 From: hoang Date: Thu, 6 Feb 2025 16:40:27 +0700 Subject: [PATCH 01/25] 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 b44872483..37e85647f 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 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 @@ -48,3 +111,7 @@ def test_routes(self): with self.subTest(viewname=viewname, params=params): 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) diff --git a/promgen/views.py b/promgen/views.py index 00090d42c..153928f2b 100644 --- a/promgen/views.py +++ b/promgen/views.py @@ -48,6 +48,7 @@ util, ) from promgen.forms import GroupMemberForm, UserPermissionForm +from promgen.mixins import PromgenGuardianPermissionMixin from promgen.shortcuts import resolve_domain logger = logging.getLogger(__name__) @@ -249,7 +250,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", @@ -269,21 +271,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 @@ -334,7 +339,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): @@ -345,7 +351,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: @@ -361,15 +369,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 @@ -377,8 +391,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 @@ -386,8 +409,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): @@ -407,7 +438,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): @@ -428,15 +460,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", @@ -471,7 +516,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): @@ -480,7 +533,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" @@ -500,7 +554,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): @@ -509,8 +564,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 @@ -521,6 +581,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 @@ -558,7 +621,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) @@ -569,6 +634,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" @@ -587,7 +659,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): @@ -611,8 +684,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") @@ -643,8 +721,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 @@ -654,6 +736,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 @@ -710,7 +795,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 @@ -719,8 +805,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): @@ -736,7 +826,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"] @@ -765,8 +856,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" @@ -789,7 +884,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 @@ -805,7 +901,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", @@ -815,7 +919,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 @@ -880,7 +986,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 @@ -912,6 +1019,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") @@ -931,7 +1045,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" @@ -961,8 +1076,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 @@ -977,8 +1096,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 @@ -993,6 +1116,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" @@ -1028,7 +1154,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 @@ -1047,6 +1174,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): @@ -1516,7 +1646,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): @@ -1576,7 +1706,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"] @@ -1653,7 +1785,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): @@ -1663,7 +1796,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): @@ -1701,7 +1835,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): @@ -1730,7 +1865,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): @@ -1779,13 +1915,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 5210e59ffac176996ac4273d0e8c2f59fae29a90 Mon Sep 17 00:00:00 2001 From: hoang Date: Thu, 8 May 2025 16:55:50 +0700 Subject: [PATCH 02/25] 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/0038_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/0038_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/0038_remove_default_group.py b/promgen/migrations/0038_remove_default_group.py new file mode 100644 index 000000000..d19f480ed --- /dev/null +++ b/promgen/migrations/0038_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", "0037_remove_project_farm_alter_farm_project"), + ] + + 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 a2c2f0ba2..dd160932c 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 153928f2b..8de882a44 100644 --- a/promgen/views.py +++ b/promgen/views.py @@ -1426,13 +1426,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) @@ -1782,12 +1775,12 @@ def build_success_message(self, message): 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) @@ -1798,7 +1791,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() @@ -1837,7 +1830,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() @@ -1867,7 +1860,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() @@ -1918,14 +1911,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 46480b0040f601332f63c3f6fc275ac815ec92d0 Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 13 May 2025 09:22:31 +0700 Subject: [PATCH 03/25] 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 c9be79c6928b06ed396cb9e27a1324d0445d024d Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 15 Apr 2025 14:51:59 +0700 Subject: [PATCH 04/25] 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 8de882a44..94b441987 100644 --- a/promgen/views.py +++ b/promgen/views.py @@ -41,6 +41,7 @@ forms, mixins, models, + permissions, plugins, prometheus, signals, @@ -122,7 +123,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", @@ -137,6 +138,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 944b5195f3a4a4761d6739bfb640293552f4974e Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 22 Apr 2025 08:58:52 +0700 Subject: [PATCH 05/25] 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 94b441987..efe032d4e 100644 --- a/promgen/views.py +++ b/promgen/views.py @@ -92,22 +92,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 e25899716dc7efe565d1fca17b19c5bd3cbe4a8c Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 22 Apr 2025 10:45:37 +0700 Subject: [PATCH 06/25] 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 efe032d4e..8ab45e22f 100644 --- a/promgen/views.py +++ b/promgen/views.py @@ -623,9 +623,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 f4a11d6d5c4da8725a724ab0c14aea8fdc6cbd9a Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 22 Apr 2025 11:01:16 +0700 Subject: [PATCH 07/25] 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 8ab45e22f..f8c4b5313 100644 --- a/promgen/views.py +++ b/promgen/views.py @@ -519,10 +519,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 5a2ffd84c9dda26335c9c203d4def15d9e19b114 Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 22 Apr 2025 11:02:33 +0700 Subject: [PATCH 08/25] 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 f8c4b5313..05c1f79c8 100644 --- a/promgen/views.py +++ b/promgen/views.py @@ -845,12 +845,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 60c89f6e83a99f44f01bc4190225c439c2a443d9 Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 22 Apr 2025 11:12:06 +0700 Subject: [PATCH 09/25] 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 05c1f79c8..5f4c57251 100644 --- a/promgen/views.py +++ b/promgen/views.py @@ -148,11 +148,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 8debace4cacdeb4567f8184854b5ff7b7f026083 Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 22 Apr 2025 13:56:55 +0700 Subject: [PATCH 10/25] 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 5f4c57251..cc2851abd 100644 --- a/promgen/views.py +++ b/promgen/views.py @@ -1282,24 +1282,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 fc1a8d0ad9948f0e4487004988ea13c3f131eab3 Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 22 Apr 2025 15:47:45 +0700 Subject: [PATCH 11/25] 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 cc2851abd..1385416cd 100644 --- a/promgen/views.py +++ b/promgen/views.py @@ -263,6 +263,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 5e4b984d2e982b2ccef8cfa5fdcfdfe94279f133 Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 22 Apr 2025 16:57:30 +0700 Subject: [PATCH 12/25] 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 1385416cd..852fe6362 100644 --- a/promgen/views.py +++ b/promgen/views.py @@ -1534,6 +1534,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 d6003b3652b44ecb8b69cdf7fc8855addf8f61d9 Mon Sep 17 00:00:00 2001 From: hoang Date: Wed, 23 Apr 2025 09:36:04 +0700 Subject: [PATCH 13/25] 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 852fe6362..08d1e858a 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 @@ -56,38 +56,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 4a1486e46d99146544147ebf9064f375c01ca9bc Mon Sep 17 00:00:00 2001 From: hoang Date: Wed, 23 Apr 2025 15:35:04 +0700 Subject: [PATCH 14/25] 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 08d1e858a..718d91d5b 100644 --- a/promgen/views.py +++ b/promgen/views.py @@ -195,48 +195,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 149d49f4434f313d60193e06194e27cf10ed1d51 Mon Sep 17 00:00:00 2001 From: hoang Date: Wed, 23 Apr 2025 23:28:52 +0700 Subject: [PATCH 15/25] 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 0ba53a26959617cddfa3451b30a33a5884198c9a Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 13 May 2025 09:53:21 +0700 Subject: [PATCH 16/25] 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 795b68a3e20d2d8215416daee0a1df34432159f0 Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 13 May 2025 09:56:09 +0700 Subject: [PATCH 17/25] 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 346aea2a73e3c2c559ae728b1229c95179bae1a1 Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 13 May 2025 09:57:31 +0700 Subject: [PATCH 18/25] 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 343f01ea323737c4eda2faf706b645d314bfa67f Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 13 May 2025 10:33:13 +0700 Subject: [PATCH 19/25] 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 74f493454b7c936bf3113af59a1ae8ebb0bb42d2 Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 13 May 2025 13:42:04 +0700 Subject: [PATCH 20/25] 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 59d165484..eff9611bc 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 53c7903a0d9ff832d7d18072d304685df368d46e Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 13 May 2025 13:56:52 +0700 Subject: [PATCH 21/25] 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 eff9611bc..64e9ebb1e 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 60fa215ff5065ef80fd4cd67a92ffa3485ee5605 Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 13 May 2025 15:52:16 +0700 Subject: [PATCH 22/25] 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 914113426a5f59730ad4433be99d4d1dea768059 Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 13 May 2025 16:21:48 +0700 Subject: [PATCH 23/25] 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 b8e015da43eccf1754acab911942392b365474f6 Mon Sep 17 00:00:00 2001 From: hoang Date: Tue, 30 Sep 2025 15:29:25 +0700 Subject: [PATCH 24/25] 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 718d91d5b..0f660d6c3 100644 --- a/promgen/views.py +++ b/promgen/views.py @@ -1957,7 +1957,9 @@ def build_success_message(self, message): 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 69ff77f73857b5436a9aff9ea1ca4c414dbf1c78 Mon Sep 17 00:00:00 2001 From: hoang Date: Mon, 6 Oct 2025 15:47:48 +0700 Subject: [PATCH 25/25] 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 6871 -> 6446 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 568d020ed098e58833352f1596e28e4301257159..a83f0afb8ee72b20ab323b803c29a1ab138a3780 100644 GIT binary patch delta 1379 zcmZwHJxo(k7{>8eY(b@h6e@_K6c9wHALXMcXaXvsGAO92L>6NqE*b_jVY-PYT~r(KkK(W zvk5AV3{0aQyoX0{9=GEvDl=gQwSWfPh@Gex4!Hgi+(i3=8=rRVYp8|XMt#r&Y{Dlv z%>4F&%0X=2K)UffrtmQ;BVUk@vk>{)j1lA!Ys3&Ha0-*ipFQNC7P^R9;7io@E@L~E zvziXN&N^@4B<%$}hB1#>u24CLqxc53vL-fKTQH7Vc@{N+huZsD%;8=urp&YJg?3R`Zv?i_Ugj_*g-VS9+JL=V9{{uYq{KSSUjT4jTG z`-|;FrTc#+MQ5uUXuvH&UFSb2WW+y&oF(fbl)gHGt@5uY_@&LMEa-abYgKj;hX{VZ zjNc?n5cPzL&O$e#txghgqJ=O!5nKswn4e}{6Q!A?*lt3Hv4hYxQ_-RPxAf6yCUh25 Un(~*zKg0Qw$Yg1LuqIdZ7dDJ&O#lD@ delta 1803 zcmZ|PPiz!b9KiACN&#stw$vj2eS%VzQUr>iRRNVmX#5jPV=!=-b_Y6jyIXd)=w6)8 z%)zu&E&m#cJ+KGe3QD7m8Uh$&JP;2YNE8qLi_a};^k(8o*n!A8mv)j0q(~RETXi3fREwg`ATiYo!E&JxE+7M z>#>!G$*M=N7Pn(Pc2~=RO52J-yq$&sHI}dmKeb-Q2Fkypbo`esH!ZCGZY3_Iz5}np zF08{nC==*M8F;^~A5O}FQm>F%LBk}x+0D z<%70;(w0x6Oyn%ef-c}4_ziY4zDg}p>H&NbAIA~gh?h`yWD)zuVpS_PV>|M!>O?uE zZY<+-IEX1mBddIr2?r<>J%kVA32ephFj!Bfj^(6r3$DR8vEwSGzCancVX;zA<1#E_ zKX&1d_$+Q>*#~eOM{xn8$`Kt#+4?D@D>aS$M4h+giw!DBD!!)Tej0wWwlperCuIk3 z!l4Ag}(kb^RTJj(M$yb&jC`Mo9VzijnIDrC#%b3nVX z5v9W%%1TZkb5kFp3~&KuOTV=B6_f%0u*|CtN0VueNn^Y?77}tRYFvIXOOCOpfe6TafMN6_flE zAW&=hUQPUyWLqW4aoj_&uq4TGNRm~vm?W*_dlkVSMcqQ&L~JA^$+pYcw-DB+ddkkgLud;79FpUb$uln2~_9yjl|>O!BZ2i^RD#}9H@-P`B(K2P`ZXm4T2 z$z+N;?fKG8x=ruwbBp?g6<#KzdtB{ilWOf{lU}m!i-oy~%&e`=)CML#nS9Qf=!^-c zuawXJR2sW-bjpOM|J_tJ;c+t@nfP53pEL0(6CE~j$;2}z9+#T%RAuCS6OC5RA2H!u z@`)ld9L`R?{qynhq_@ySWfR9Hnr0wIG0|(LD;hK5$0mHkL`Ud-_MOsy2mYTf#%3o+ XDx+sAV-qGkW^6w)#hHob*HeE3ndCZj diff --git a/promgen/locale/ja/LC_MESSAGES/django.po b/promgen/locale/ja/LC_MESSAGES/django.po index 3503a510c..2c2f44594 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 %}