diff --git a/docker/requirements.txt b/docker/requirements.txt
index 5731b5760..54c02d833 100644
--- a/docker/requirements.txt
+++ b/docker/requirements.txt
@@ -54,6 +54,8 @@ django-environ==0.10.0
# via promgen (pyproject.toml)
django-filter==23.2
# via promgen (pyproject.toml)
+django-guardian==2.4.0
+ # via promgen (pyproject.toml)
djangorestframework==3.14.0
# via promgen (pyproject.toml)
gunicorn==22.0.0
diff --git a/promgen/admin.py b/promgen/admin.py
index 1d90c12fb..79bee7b5a 100644
--- a/promgen/admin.py
+++ b/promgen/admin.py
@@ -2,6 +2,7 @@
# These sources are released under the terms of the MIT license: see LICENSE
import json
+import guardian.admin
from django import forms
from django.contrib import admin
from django.utils.html import format_html
@@ -35,14 +36,14 @@ class ShardAdmin(admin.ModelAdmin):
@admin.register(models.Service)
-class ServiceAdmin(admin.ModelAdmin):
+class ServiceAdmin(guardian.admin.GuardedModelAdmin):
list_display = ("name", "owner")
list_filter = (("owner", admin.RelatedOnlyFieldListFilter),)
list_select_related = ("owner",)
@admin.register(models.Project)
-class ProjectAdmin(admin.ModelAdmin):
+class ProjectAdmin(guardian.admin.GuardedModelAdmin):
list_display = ("name", "shard", "service", "farm", "owner")
list_select_related = ("service", "farm", "shard", "owner")
list_filter = ("shard", ("owner", admin.RelatedOnlyFieldListFilter))
@@ -68,7 +69,7 @@ class SenderAdmin(admin.ModelAdmin):
@admin.register(models.Farm)
-class FarmAdmin(admin.ModelAdmin):
+class FarmAdmin(guardian.admin.GuardedModelAdmin):
list_display = ("name", "source")
list_filter = ("source",)
diff --git a/promgen/forms.py b/promgen/forms.py
index 857213431..71dc6f349 100644
--- a/promgen/forms.py
+++ b/promgen/forms.py
@@ -6,7 +6,9 @@
from dateutil import parser
from django import forms
+from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
+from guardian.shortcuts import get_perms_for_model
from promgen import errors, models, plugins, prometheus, validators
@@ -254,3 +256,38 @@ def clean(self):
if not hosts:
raise ValidationError("No valid hosts")
self.cleaned_data["hosts"] = list(hosts)
+
+
+class UserPermissionForm(forms.Form):
+ permission = forms.ChoiceField(
+ required=True,
+ label="Permission",
+ )
+
+ username = forms.ChoiceField(
+ required=True,
+ label="Username",
+ )
+
+ def __init__(self, *args, **kwargs):
+ input_object = kwargs.pop("input_object", None)
+ super(UserPermissionForm, self).__init__(*args, **kwargs)
+ if input_object:
+ self.fields["permission"].choices = self.get_permission_choices(input_object)
+ self.fields["username"].choices = self.get_user_choices()
+
+ def get_permission_choices(self, input_object):
+ permissions = get_perms_for_model(input_object)
+ for permission in permissions:
+ yield (permission.codename, permission.name)
+
+ def get_user_choices(self):
+ for u in (User.objects.filter(is_active=True, is_superuser=False)
+ .exclude(username="AnonymousUser")
+ .order_by("username")):
+ if u.first_name:
+ yield (u.username, f"{u.username} ({u.first_name} {u.last_name})")
+ elif u.email:
+ yield (u.username, f"{u.username} ({u.email})")
+ else:
+ yield (u.username, u.username)
diff --git a/promgen/migrations/0024_alter_farm_name_alter_project_name_alter_rule_name_and_more.py b/promgen/migrations/0024_alter_farm_name_alter_project_name_alter_rule_name_and_more.py
new file mode 100644
index 000000000..c3c178d8a
--- /dev/null
+++ b/promgen/migrations/0024_alter_farm_name_alter_project_name_alter_rule_name_and_more.py
@@ -0,0 +1,39 @@
+# Generated by Django 4.2.11 on 2025-02-03 02:50
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('promgen', '0023_shard_authorization'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='farm',
+ name='name',
+ field=models.CharField(max_length=128, validators=[django.core.validators.RegexValidator('^[\\w][- \\w]+$', 'Unicode letters, numbers, underscores, or hyphens or spaces')]),
+ ),
+ migrations.AlterField(
+ model_name='project',
+ name='name',
+ field=models.CharField(max_length=128, unique=True, validators=[django.core.validators.RegexValidator('^[\\w][- \\w]+$', 'Unicode letters, numbers, underscores, or hyphens or spaces')]),
+ ),
+ migrations.AlterField(
+ model_name='rule',
+ name='name',
+ field=models.CharField(max_length=128, unique=True, validators=[django.core.validators.RegexValidator('^[a-zA-Z_:][a-zA-Z0-9_:]*$', 'Only alphanumeric characters are allowed.')]),
+ ),
+ migrations.AlterField(
+ model_name='service',
+ name='name',
+ field=models.CharField(max_length=128, unique=True, validators=[django.core.validators.RegexValidator('^[\\w][- \\w]+$', 'Unicode letters, numbers, underscores, or hyphens or spaces')]),
+ ),
+ migrations.AlterField(
+ model_name='shard',
+ name='name',
+ field=models.CharField(max_length=128, unique=True, validators=[django.core.validators.RegexValidator('^[\\w][- \\w]+$', 'Unicode letters, numbers, underscores, or hyphens or spaces')]),
+ ),
+ ]
diff --git a/promgen/migrations/0025_farm_owner.py b/promgen/migrations/0025_farm_owner.py
new file mode 100644
index 000000000..c45549ead
--- /dev/null
+++ b/promgen/migrations/0025_farm_owner.py
@@ -0,0 +1,21 @@
+# Generated by Django 4.2.11 on 2025-02-05 08:11
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('promgen', '0024_alter_farm_name_alter_project_name_alter_rule_name_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='farm',
+ name='owner',
+ field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL),
+ ),
+ ]
diff --git a/promgen/migrations/0026_alter_farm_options_alter_project_options_and_more.py b/promgen/migrations/0026_alter_farm_options_alter_project_options_and_more.py
new file mode 100644
index 000000000..b4054cc97
--- /dev/null
+++ b/promgen/migrations/0026_alter_farm_options_alter_project_options_and_more.py
@@ -0,0 +1,25 @@
+# Generated by Django 4.2.11 on 2025-02-05 08:59
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('promgen', '0025_farm_owner'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='farm',
+ options={'default_permissions': ('manage', 'edit'), 'ordering': ['name']},
+ ),
+ migrations.AlterModelOptions(
+ name='project',
+ options={'default_permissions': ('manage', 'edit'), 'ordering': ['name']},
+ ),
+ migrations.AlterModelOptions(
+ name='service',
+ options={'default_permissions': ('manage', 'edit'), 'ordering': ['name']},
+ ),
+ ]
diff --git a/promgen/mixins.py b/promgen/mixins.py
index 84aaad5f9..b6f624026 100644
--- a/promgen/mixins.py
+++ b/promgen/mixins.py
@@ -1,11 +1,12 @@
# Copyright (c) 2019 LINE Corporation
# These sources are released under the terms of the MIT license: see LICENSE
-
+import guardian.mixins
+import guardian.utils
from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.auth.views import redirect_to_login
from django.contrib.contenttypes.models import ContentType
-from django.shortcuts import get_object_or_404
+from django.shortcuts import get_object_or_404, redirect
from django.views.generic.base import ContextMixin
from promgen import models
@@ -78,3 +79,67 @@ def get_context_data(self, **kwargs):
models.Service, id=self.kwargs["pk"]
)
return context
+
+
+class PromgenGuardianPermissionMixin(guardian.mixins.PermissionRequiredMixin):
+
+ def get_check_permission_object(self):
+ # Override this method to return the object to check permissions for
+ return self.get_object()
+
+ def get_check_permission_objects(self):
+ # We only define permission for Service/Project/Farm
+ # So we need to check the permission for the parent objects in other cases
+ try:
+ object = self.get_check_permission_object()
+ if isinstance(object, models.Farm):
+ return [object]
+ elif isinstance(object, models.Host):
+ return [object, object.farm]
+ elif isinstance(object, models.Service):
+ return [object]
+ elif isinstance(object, models.Project):
+ return [object, object.service]
+ elif isinstance(object, models.Exporter) or isinstance(object, models.URL):
+ return [object.project, object.project.service]
+ elif isinstance(object, models.Rule) or isinstance(object, models.Sender):
+ if isinstance(object.content_object, models.Project):
+ return [object.content_object, object.content_object.service]
+ else:
+ return [object.content_object]
+ except:
+ return None
+
+ def check_permissions(self, request):
+ check_permission_objects = self.get_check_permission_objects()
+ if check_permission_objects is None:
+ if request.user.is_active and request.user.is_superuser:
+ return None
+ return self.on_permission_check_fail(request, None)
+ # Loop through all the objects to check permissions for
+ # If any of the objects has the required permission (any_perm=True), we can proceed
+ # Otherwise, we will return the forbidden response
+ forbidden = None
+ for obj in check_permission_objects:
+ forbidden = guardian.utils.get_40x_or_None(request,
+ perms=self.get_required_permissions(
+ request),
+ obj=obj,
+ login_url=self.login_url,
+ redirect_field_name=self.redirect_field_name,
+ return_403=self.return_403,
+ return_404=self.return_404,
+ accept_global_perms=False,
+ any_perm=True,
+ )
+ if forbidden is None:
+ break
+ if forbidden:
+ return self.on_permission_check_fail(request, forbidden)
+
+ def on_permission_check_fail(self, request, response, obj=None):
+ messages.warning(request, "You do not have permission to perform this action.")
+ referer = request.META.get("HTTP_REFERER")
+ if referer:
+ return redirect(referer)
+ return redirect_to_login(self.request.get_full_path())
diff --git a/promgen/models.py b/promgen/models.py
index d99467a82..3993fd9b3 100644
--- a/promgen/models.py
+++ b/promgen/models.py
@@ -220,6 +220,7 @@ class Service(models.Model):
class Meta:
ordering = ["name"]
+ default_permissions = ("manage", "edit")
def get_absolute_url(self):
return reverse("service-detail", kwargs={"pk": self.pk})
@@ -255,6 +256,7 @@ class Project(models.Model):
class Meta:
ordering = ["name"]
+ default_permissions = ("manage", "edit")
def get_absolute_url(self):
return reverse("project-detail", kwargs={"pk": self.pk})
@@ -266,10 +268,14 @@ def __str__(self):
class Farm(models.Model):
name = models.CharField(max_length=128, validators=[validators.labelvalue])
source = models.CharField(max_length=128)
+ owner = models.ForeignKey(
+ settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, default=None
+ )
class Meta:
ordering = ["name"]
unique_together = (("name", "source"),)
+ default_permissions = ("manage", "edit")
def get_absolute_url(self):
return reverse("farm-detail", kwargs={"pk": self.pk})
diff --git a/promgen/settings.py b/promgen/settings.py
index 190385e29..505591865 100644
--- a/promgen/settings.py
+++ b/promgen/settings.py
@@ -65,6 +65,7 @@
"rest_framework.authtoken",
"rest_framework",
"social_django",
+ "guardian",
# Django
"django.forms",
"django.contrib.admin",
@@ -222,3 +223,8 @@
globals()[k] = v
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
+
+AUTHENTICATION_BACKENDS = (
+ "django.contrib.auth.backends.ModelBackend",
+ "guardian.backends.ObjectPermissionBackend",
+)
\ No newline at end of file
diff --git a/promgen/signals.py b/promgen/signals.py
index 2116035fa..3827b6399 100644
--- a/promgen/signals.py
+++ b/promgen/signals.py
@@ -10,6 +10,7 @@
from django.core.cache import cache
from django.db.models.signals import post_delete, post_save, pre_delete, pre_save
from django.dispatch import Signal, receiver
+from guardian.shortcuts import assign_perm, UserObjectPermission, GroupObjectPermission
from promgen import models, prometheus, tasks
@@ -333,3 +334,27 @@ def add_default_project_subscription(instance, created, **kwargs):
value=instance.owner.username,
defaults={"owner": instance.owner},
)
+
+
+@skip_raw
+def assign_manage_permission(sender, instance, created, **kwargs):
+ # assign manage permission to the owner of the instance when it is created
+ if created and instance.owner:
+ assign_perm("manage_" + sender._meta.model_name, instance.owner, instance)
+
+
+post_save.connect(assign_manage_permission, sender=models.Service)
+post_save.connect(assign_manage_permission, sender=models.Project)
+post_save.connect(assign_manage_permission, sender=models.Farm)
+
+
+@skip_raw
+def cleanup_permissions(sender, instance, **kwargs):
+ # remove all object permissions when the instance is deleted
+ UserObjectPermission.objects.filter(object_pk=instance.pk).delete()
+ GroupObjectPermission.objects.filter(object_pk=instance.pk).delete()
+
+
+post_delete.connect(cleanup_permissions, sender=models.Service)
+post_delete.connect(cleanup_permissions, sender=models.Project)
+post_delete.connect(cleanup_permissions, sender=models.Farm)
diff --git a/promgen/templates/promgen/farm_detail.html b/promgen/templates/promgen/farm_detail.html
index 8e031433d..c8914470d 100644
--- a/promgen/templates/promgen/farm_detail.html
+++ b/promgen/templates/promgen/farm_detail.html
@@ -4,7 +4,11 @@
{% block content %}
@@ -67,4 +71,9 @@ Farm: {{ farm.name }} ({{ farm.source }})
+
+
Permissions
+ {% include "promgen/permission_block.html" with object=farm %}
+
+
{% endblock %}
diff --git a/promgen/templates/promgen/permission_block.html b/promgen/templates/promgen/permission_block.html
new file mode 100644
index 000000000..cbb0b63ab
--- /dev/null
+++ b/promgen/templates/promgen/permission_block.html
@@ -0,0 +1,38 @@
+{% load i18n %}
+{% load promgen %}
+
+
+
+ User |
+ Email |
+
+
+ Permission
+
+ |
+ Actions |
+
+ {% for user,perms in object|get_users_permissions %}
+ {% include 'promgen/permission_row.html' %}
+ {% endfor %}
+
+
+
+
diff --git a/promgen/templates/promgen/permission_row.html b/promgen/templates/promgen/permission_row.html
new file mode 100644
index 000000000..43089deaa
--- /dev/null
+++ b/promgen/templates/promgen/permission_row.html
@@ -0,0 +1,26 @@
+{% load i18n %}
+{% load promgen %}
+
+ {{ user.username }} |
+ {{ user.email }} |
+
+ {% for perm in perms %}
+ {{ perm|upper }}
+ {% endfor %}
+ |
+
+
+ |
+
+
+
+
+
diff --git a/promgen/templates/promgen/project_detail.html b/promgen/templates/promgen/project_detail.html
index 6842466bf..0e43f2776 100644
--- a/promgen/templates/promgen/project_detail.html
+++ b/promgen/templates/promgen/project_detail.html
@@ -66,5 +66,6 @@
{% include "promgen/project_detail_rules.html" %}
+{% include "promgen/project_detail_permissions.html" %}
{% endblock %}
diff --git a/promgen/templates/promgen/project_detail_permissions.html b/promgen/templates/promgen/project_detail_permissions.html
new file mode 100644
index 000000000..8b4e07130
--- /dev/null
+++ b/promgen/templates/promgen/project_detail_permissions.html
@@ -0,0 +1,6 @@
+{% load i18n %}
+
+
+
Permissions
+ {% 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 361aaad07..f2e3a46fd 100644
--- a/promgen/templates/promgen/service_detail.html
+++ b/promgen/templates/promgen/service_detail.html
@@ -25,6 +25,7 @@ Service: {{ service.name }}
- Projects
- Rules
- Notifiers
+ - Permissions
@@ -51,6 +52,13 @@
Service: {{ service.name }}
{% include "promgen/service_block_panel_notifiers.inc.html" %}
+
+
+
+ {% include "promgen/permission_block.html" with object=service %}
+
+
+
diff --git a/promgen/templatetags/promgen.py b/promgen/templatetags/promgen.py
index 3a1c2c157..eb371934b 100644
--- a/promgen/templatetags/promgen.py
+++ b/promgen/templatetags/promgen.py
@@ -14,6 +14,7 @@
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
+from guardian.shortcuts import get_users_with_perms
register = template.Library()
@@ -214,3 +215,9 @@ def urlqs(view, **kwargs):
This only works for views that do not need additional parameters
"""
return reverse(view) + "?" + urlencode(kwargs)
+
+
+@register.filter()
+def get_users_permissions(object):
+ return get_users_with_perms(object, attach_perms=True, with_superusers=False,
+ with_group_users=False).items()
diff --git a/promgen/tests/examples/rest.farm.1.json b/promgen/tests/examples/rest.farm.1.json
index 28fa5c788..09d9c7cfa 100644
--- a/promgen/tests/examples/rest.farm.1.json
+++ b/promgen/tests/examples/rest.farm.1.json
@@ -3,6 +3,7 @@
"url": "http://promgen.example.com/farm/1",
"name": "test-farm",
"source": "promgen",
+ "owner": null,
"hosts": [
{
"name": "host.example.com",
diff --git a/promgen/tests/examples/rest.farm.json b/promgen/tests/examples/rest.farm.json
index 33f21f7b4..f6abe8f9a 100644
--- a/promgen/tests/examples/rest.farm.json
+++ b/promgen/tests/examples/rest.farm.json
@@ -2,6 +2,8 @@
{
"id": 1,
"url": "http://promgen.example.com/farm/1",
- "name": "test-farm","source":"promgen"
+ "name": "test-farm",
+ "source":"promgen",
+ "owner": null
}
]
diff --git a/promgen/tests/test_host_add.py b/promgen/tests/test_host_add.py
index 41c52d0e0..f2e3be57f 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,7 @@ def setUp(self):
# separated and comma separated work, but are not necessarily testing
# valid/invalid hostnames
def test_newline(self):
+ assign_perm("promgen.edit_farm", get_current_user(), get_object_or_404(models.Farm, pk=1))
self.client.post(
reverse("hosts-add", args=[1]),
{"hosts": "\naaa.example.com\nbbb.example.com\nccc.example.com \n"},
@@ -24,6 +26,7 @@ def test_newline(self):
self.assertCount(models.Host, 3, "Expected 3 hosts")
def test_comma(self):
+ assign_perm("promgen.edit_farm", get_current_user(), get_object_or_404(models.Farm, 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..bbf2f237c
--- /dev/null
+++ b/promgen/tests/test_mixins.py
@@ -0,0 +1,76 @@
+# 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.shortcuts import get_object_or_404
+from django.test import RequestFactory
+from guardian.shortcuts import assign_perm
+
+from promgen import models
+from promgen import 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("/example-url")
+
+ def test_permission_granted(self):
+ user = self.force_login(username="demo")
+ object = get_object_or_404(models.Project, pk=1)
+ permission_required = "manage_project"
+ assign_perm(permission_required, user, object)
+ self.view.permission_required = permission_required
+ 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 = "manage_project"
+ self.view.permission_required = permission_required
+ 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 = "manage_service"
+ assign_perm(permission_required, user, object)
+ self.view.permission_required = permission_required
+ 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")
+ permission_required = "manage_service"
+ assign_perm(permission_required, user, another_object)
+ self.view.permission_required = permission_required
+ 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 32f05f2df..258ae6c30 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 8009830ac..812481e07 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 views, models
+from promgen.tests import PromgenTest
-class WebTests(tests.PromgenTest):
+class WebTests(PromgenTest):
fixtures = ["testcases.yaml", "extras.yaml"]
route_map = [
@@ -15,9 +17,31 @@ class WebTests(tests.PromgenTest):
("service-list", views.ServiceList, {}),
("service-detail", views.ServiceDetail, {"pk": 1}),
("project-detail", views.ProjectDetail, {"pk": 1}),
- ("farm-link", views.FarmLink, {"pk": 1, "source": "promgen"}),
- ("project-exporter", views.ExporterRegister, {"pk": 1}),
- ("project-notifier", views.ProjectNotifierRegister, {"pk": 1}),
+ ("farm-link", views.FarmLink,
+ {
+ "pk": 1,
+ "source": "promgen",
+ "permission": "edit_project",
+ "model": models.Project,
+ "permission_object_pk": 1
+ }
+ ),
+ ("project-exporter", views.ExporterRegister,
+ {
+ "pk": 1,
+ "permission": "edit_project",
+ "model": models.Project,
+ "permission_object_pk": 1
+ }
+ ),
+ ("project-notifier", views.ProjectNotifierRegister,
+ {
+ "pk": 1,
+ "permission": "edit_project",
+ "model": models.Project,
+ "permission_object_pk": 1
+ }
+ ),
("url-list", views.URLList, {}),
("farm-list", views.FarmList, {}),
("farm-detail", views.FarmDetail, {"pk": 1}),
@@ -40,6 +64,13 @@ def setUp(self):
def test_routes(self):
for viewname, viewclass, params in self.route_map:
+ permission = params.pop("permission", None)
+ permission_model = params.pop("model", None)
+ permission_object_pk = params.pop("permission_object_pk", None)
+ if permission and permission_model and permission_object_pk:
+ permission_object = permission_model.objects.get(pk=permission_object_pk)
+ assign_perm(permission, self.user, permission_object)
+
# By default we'll pass all params as-is to our reverse()
# method, but we may have a few special ones (like status_code)
# that we want to pop and handle separately
@@ -49,3 +80,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/urls.py b/promgen/urls.py
index 70b69252f..8968e2b41 100644
--- a/promgen/urls.py
+++ b/promgen/urls.py
@@ -87,6 +87,9 @@
path("rule//toggle", views.RuleToggle.as_view(), name="rule-toggle"),
path("rule//test", csrf_exempt(views.RuleTest.as_view()), name="rule-test"),
path("rule//duplicate", views.RulesCopy.as_view(), name="rule-overwrite"),
+ # Permissions
+ path("permission/assign", views.PermissionAssign.as_view(), name="permission-assign"),
+ path("permission/delete", views.PermissionDelete.as_view(), name="permission-delete"),
# Generic Rules
path("//rule", views.AlertRuleRegister.as_view(), name="rule-new"),
# Other miscellaneous
diff --git a/promgen/views.py b/promgen/views.py
index f92c08a37..46d521925 100644
--- a/promgen/views.py
+++ b/promgen/views.py
@@ -15,6 +15,7 @@
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, Q
from django.db.utils import IntegrityError
@@ -27,6 +28,7 @@
from django.views.generic.base import RedirectView, TemplateView
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import CreateView, DeleteView, FormView
+from guardian.shortcuts import get_perms, remove_perm, assign_perm
from prometheus_client.core import CounterMetricFamily, GaugeMetricFamily
from prometheus_client.parser import text_string_to_metric_families
from requests.exceptions import HTTPError
@@ -43,6 +45,8 @@
tasks,
util,
)
+from promgen.forms import UserPermissionForm
+from promgen.mixins import PromgenGuardianPermissionMixin
from promgen.shortcuts import resolve_domain
logger = logging.getLogger(__name__)
@@ -282,22 +286,30 @@ class ServiceDetail(LoginRequiredMixin, DetailView):
"project_set__notifiers__owner",
)
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["permission_form"] = UserPermissionForm(input_object=self.object)
+ return context
-class ServiceDelete(LoginRequiredMixin, DeleteView):
+
+class ServiceDelete(PromgenGuardianPermissionMixin, DeleteView):
+ permission_required = ["manage_service"]
model = models.Service
def get_success_url(self):
return reverse("service-list")
-class ProjectDelete(LoginRequiredMixin, DeleteView):
+class ProjectDelete(PromgenGuardianPermissionMixin, DeleteView):
+ permission_required = ["manage_service", "manage_project"]
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 = ["manage_service", "edit_service", "manage_project", "edit_project"]
model = models.Sender
form_class = forms.NotifierUpdate
@@ -348,7 +360,8 @@ def post(self, request, pk):
return self.get(self, request, pk)
-class NotifierDelete(LoginRequiredMixin, DeleteView):
+class NotifierDelete(PromgenGuardianPermissionMixin, DeleteView):
+ permission_required = ["manage_service", "edit_service", "manage_project", "edit_project"]
model = models.Sender
def get_success_url(self):
@@ -359,7 +372,8 @@ def get_success_url(self):
return reverse("profile")
-class NotifierTest(LoginRequiredMixin, View):
+class NotifierTest(PromgenGuardianPermissionMixin, View):
+ permission_required = ["manage_service", "edit_service", "manage_project", "edit_project"]
def post(self, request, pk):
sender = get_object_or_404(models.Sender, id=pk)
try:
@@ -375,15 +389,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 = ["manage_service", "edit_service", "manage_project", "edit_project"]
model = models.Exporter
def get_success_url(self):
return reverse("project-detail", args=[self.object.project_id])
-class ExporterToggle(LoginRequiredMixin, View):
+class ExporterToggle(PromgenGuardianPermissionMixin, View):
+ permission_required = ["manage_service", "edit_service", "manage_project", "edit_project"]
+
def post(self, request, pk):
exporter = get_object_or_404(models.Exporter, id=pk)
exporter.enabled = not exporter.enabled
@@ -391,8 +411,13 @@ def post(self, request, pk):
signals.trigger_write_config.send(request)
return JsonResponse({"redirect": exporter.project.get_absolute_url()})
+ def get_check_permission_object(self):
+ return get_object_or_404(models.Exporter, id=self.kwargs["pk"])
+
+
+class NotifierToggle(PromgenGuardianPermissionMixin, View):
+ permission_required = ["manage_service", "edit_service", "manage_project", "edit_project"]
-class NotifierToggle(LoginRequiredMixin, View):
def post(self, request, pk):
sender = get_object_or_404(models.Sender, id=pk)
sender.enabled = not sender.enabled
@@ -400,8 +425,12 @@ def post(self, request, pk):
# Redirect to current page
return JsonResponse({"redirect": ""})
+ def get_check_permission_object(self):
+ return get_object_or_404(models.Sender, id=self.kwargs["pk"])
+
-class RuleDelete(mixins.PromgenPermissionMixin, DeleteView):
+class RuleDelete(PromgenGuardianPermissionMixin, DeleteView):
+ permission_required = ["manage_service", "edit_service", "manage_project", "edit_project"]
model = models.Rule
def get_permission_denied_message(self):
@@ -421,8 +450,8 @@ def get_success_url(self):
return self.object.content_object.get_absolute_url()
-class RuleToggle(mixins.PromgenPermissionMixin, SingleObjectMixin, View):
- model = models.Rule
+class RuleToggle(PromgenGuardianPermissionMixin, SingleObjectMixin, View):
+ permission_required = ["manage_service", "edit_service", "manage_project", "edit_project"]
def get_permission_denied_message(self):
return "Unable to toggle rule %s. User lacks permission" % self.object
@@ -443,7 +472,8 @@ def post(self, request, pk):
return JsonResponse({"redirect": self.object.content_object.get_absolute_url()})
-class HostDelete(LoginRequiredMixin, DeleteView):
+class HostDelete(PromgenGuardianPermissionMixin, DeleteView):
+ permission_required = ["manage_farm", "edit_farm"]
model = models.Host
def get_success_url(self):
@@ -470,6 +500,7 @@ def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["sources"] = models.Farm.driver_set()
context["url_form"] = forms.URLForm()
+ context["permission_form"] = UserPermissionForm(input_object=self.object)
return context
@@ -484,8 +515,14 @@ class FarmList(LoginRequiredMixin, ListView):
class FarmDetail(LoginRequiredMixin, DetailView):
model = models.Farm
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["permission_form"] = UserPermissionForm(input_object=self.object)
+ return context
+
-class FarmUpdate(LoginRequiredMixin, UpdateView):
+class FarmUpdate(PromgenGuardianPermissionMixin, UpdateView):
+ permission_required = ["manage_farm", "edit_farm"]
model = models.Farm
button_label = _("Update Farm")
template_name = "promgen/farm_form.html"
@@ -505,7 +542,8 @@ def form_valid(self, form):
return HttpResponseRedirect(reverse("project-detail", args=[farm.project_set.first().id]))
-class FarmDelete(LoginRequiredMixin, RedirectView):
+class FarmDelete(PromgenGuardianPermissionMixin, RedirectView):
+ permission_required = ["manage_farm"]
pattern_name = "farm-detail"
def post(self, request, pk):
@@ -514,8 +552,13 @@ def post(self, request, pk):
return HttpResponseRedirect(request.POST.get("next", reverse("service-list")))
+ def get_check_permission_object(self):
+ return get_object_or_404(models.Farm, id=self.kwargs["pk"])
+
+
+class UnlinkFarm(PromgenGuardianPermissionMixin, View):
+ permission_required = ["manage_service", "edit_service", "manage_project", "edit_project"]
-class UnlinkFarm(LoginRequiredMixin, View):
def post(self, request, pk):
project = get_object_or_404(models.Project, id=pk)
oldfarm, project.farm = project.farm, None
@@ -528,6 +571,9 @@ def post(self, request, pk):
return HttpResponseRedirect(reverse("project-detail", args=[project.id]))
+ def get_check_permission_object(self):
+ return get_object_or_404(models.Project, id=self.kwargs["pk"])
+
class RulesList(LoginRequiredMixin, ListView, mixins.ServiceMixin):
template_name = "promgen/rule_list.html"
@@ -591,7 +637,8 @@ def post(self, request, pk):
return redirect(farm)
-class FarmConvert(LoginRequiredMixin, RedirectView):
+class FarmConvert(PromgenGuardianPermissionMixin, RedirectView):
+ permission_required = ["manage_farm", "edit_farm"]
pattern_name = "farm-detail"
def post(self, request, pk):
@@ -615,8 +662,12 @@ 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(LoginRequiredMixin, View):
+class FarmLink(PromgenGuardianPermissionMixin, View):
+ permission_required = ["manage_service", "edit_service", "manage_project", "edit_project"]
def get(self, request, pk, source):
context = {
"source": source,
@@ -639,8 +690,12 @@ def post(self, request, pk, source):
project.save()
return HttpResponseRedirect(reverse("project-detail", args=[project.id]))
+ 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 = ["manage_service", "edit_service", "manage_project", "edit_project"]
model = models.Exporter
template_name = "promgen/exporter_form.html"
form_class = forms.ExporterForm
@@ -650,6 +705,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]))
+ 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
@@ -705,7 +763,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 = ["manage_service", "edit_service", "manage_project", "edit_project"]
model = models.URL
template_name = "promgen/url_form.html"
form_class = forms.URLForm
@@ -715,8 +774,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]))
+ 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 = ["manage_service", "edit_service", "manage_project", "edit_project"]
model = models.URL
def get_success_url(self):
@@ -732,7 +795,8 @@ class URLList(LoginRequiredMixin, ListView):
)
-class ProjectRegister(LoginRequiredMixin, CreateView):
+class ProjectRegister(PromgenGuardianPermissionMixin, CreateView):
+ permission_required = ["manage_service", "edit_service"]
button_label = _("Register Project")
model = models.Project
fields = ["name", "description", "owner", "shard"]
@@ -753,8 +817,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 = ["manage_service", "edit_service", "manage_project", "edit_project"]
model = models.Project
button_label = _("Project Update")
template_name = "promgen/project_form.html"
@@ -767,7 +835,8 @@ def get_context_data(self, **kwargs):
return context
-class ServiceUpdate(LoginRequiredMixin, UpdateView):
+class ServiceUpdate(PromgenGuardianPermissionMixin, UpdateView):
+ permission_required = ["manage_service", "edit_service"]
button_label = _("Update Service")
form_class = forms.ServiceUpdate
model = models.Service
@@ -783,7 +852,8 @@ class RuleDetail(LoginRequiredMixin, DetailView):
)
-class RuleUpdate(mixins.PromgenPermissionMixin, UpdateView):
+class RuleUpdate(PromgenGuardianPermissionMixin, UpdateView):
+ permission_required = ["manage_service", "edit_service", "manage_project", "edit_project"]
def get_permission_denied_message(self):
return "Unable to edit rule %s. User lacks permission" % self.object
@@ -848,7 +918,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 = ["manage_service", "edit_service", "manage_project", "edit_project"]
model = models.Rule
template_name = "promgen/rule_register.html"
form_class = forms.AlertRuleForm
@@ -880,6 +951,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")
@@ -890,7 +968,8 @@ def get_initial(self):
return {"owner": self.request.user}
-class FarmRegister(LoginRequiredMixin, FormView, mixins.ProjectMixin):
+class FarmRegister(PromgenGuardianPermissionMixin, FormView, mixins.ProjectMixin):
+ permission_required = ["manage_service", "edit_service", "manage_project", "edit_project"]
model = models.Farm
button_label = _("Register Farm")
template_name = "promgen/farm_form.html"
@@ -898,13 +977,23 @@ class FarmRegister(LoginRequiredMixin, FormView, mixins.ProjectMixin):
def form_valid(self, form):
project = get_object_or_404(models.Project, id=self.kwargs["pk"])
- farm, _ = models.Farm.objects.get_or_create(source=discovery.FARM_DEFAULT, **form.clean())
+ farm, _ = models.Farm.objects.get_or_create(
+ source=discovery.FARM_DEFAULT, **form.clean(),
+ defaults = {"owner": self.request.user},
+ )
project.farm = farm
project.save()
return HttpResponseRedirect(project.get_absolute_url())
+ def get_initial(self):
+ return {"owner": self.request.user}
+
+ 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 = ["manage_service", "edit_service", "manage_project", "edit_project"]
model = models.Sender
template_name = "promgen/notifier_form.html"
form_class = forms.SenderForm
@@ -919,8 +1008,12 @@ def form_valid(self, form):
signals.check_user_subscription(models.Sender, sender, created, self.request)
return HttpResponseRedirect(project.get_absolute_url())
+ 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 = ["manage_service", "edit_service"]
model = models.Sender
template_name = "promgen/notifier_form.html"
form_class = forms.SenderForm
@@ -935,6 +1028,9 @@ def form_valid(self, form):
signals.check_user_subscription(models.Sender, sender, created, self.request)
return HttpResponseRedirect(service.get_absolute_url())
+ 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"
@@ -969,7 +1065,8 @@ def form_valid(self, form):
return redirect("profile")
-class HostRegister(LoginRequiredMixin, FormView):
+class HostRegister(PromgenGuardianPermissionMixin, FormView):
+ permission_required = ["manage_farm", "edit_farm"]
model = models.Host
template_name = "promgen/host_form.html"
form_class = forms.HostForm
@@ -991,6 +1088,9 @@ def form_valid(self, form):
return redirect("farm-detail", pk=farm.id)
return redirect("project-detail", pk=farm.project_set.first().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):
@@ -1417,3 +1517,56 @@ def get(self, request):
return util.proxy_error(response)
return HttpResponse(response.content, content_type="application/json")
+
+
+class PermissionAssign(PromgenGuardianPermissionMixin, View):
+ permission_required = ["manage_service", "manage_project", "manage_farm"]
+
+ def post(self, request):
+ user = User.objects.get_by_natural_key(request.POST["username"])
+ permission = request.POST["permission"]
+ obj = self.get_object()
+
+ # User should only have one permission MANAGE or EDIT for an object
+ # So we remove all permissions before assigning new one
+ permissions = get_perms(user, obj)
+ for perm in permissions:
+ remove_perm(perm, user, obj)
+
+ assign_perm(permission, user, obj)
+ messages.success(
+ request,
+ "Assigned permission: {} for user: {} on: {}".format(permission, user.username,
+ obj.name),
+ )
+ return redirect(request.POST["next"])
+
+ def get_object(self):
+ id = self.request.POST["id"]
+ model = self.request.POST["model"]
+ models = ContentType.objects.get(app_label="promgen", model=model)
+ obj = models.get_object_for_this_type(pk=id)
+ return obj
+
+
+class PermissionDelete(PromgenGuardianPermissionMixin, View):
+ permission_required = ["manage_service", "manage_project", "manage_farm"]
+
+ def post(self, request):
+ user = User.objects.get_by_natural_key(request.POST["username"])
+ obj = self.get_object()
+ permissions = get_perms(user, obj)
+ for perm in permissions:
+ remove_perm(perm, user, obj)
+ messages.success(
+ request,
+ "Removed all permissions of user: {} on: {}".format(user.username, obj.name),
+ )
+ return redirect(request.POST["next"])
+
+ def get_object(self):
+ id = self.request.POST["id"]
+ model = self.request.POST["model"]
+ models = ContentType.objects.get(app_label="promgen", model=model)
+ obj = models.get_object_for_this_type(pk=id)
+ return obj
diff --git a/pyproject.toml b/pyproject.toml
index f6faa3bf5..414827d20 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,6 +25,7 @@ dependencies = [
"celery",
"django-environ",
"django-filter",
+ "django-guardian",
"djangorestframework",
"kombu",
"prometheus-client",