Skip to content

Commit f662a91

Browse files
[IMPROVEMENT] Add per-object permission checks #658
2 parents 4d6ca2c + cfc98ad commit f662a91

62 files changed

Lines changed: 1740 additions & 253 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/user/permissions.rst

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Permissions can be assigned at both the **Service** and **Project** levels.
1313

1414
Each user or group can have at most one permission per Service or Project: **Admin**, **Editor** and **Viewer**.
1515

16-
- **Admin**: Full control over the Service or Project, including managing permissions.
16+
- **Admin**: Full control over the Service or Project, including managing permissions. **Note:** Only the owner can delete the Service or Project.
1717
- **Editor**: Can modify the Service or Project but cannot delete it or manage permissions.
1818
- **Viewer**: Read-only access to the Service or Project.
1919

@@ -45,12 +45,13 @@ The following table summarizes the permissions inheritance:
4545
| Exporter/URL/Farm/Host | Full control | Full control | View | Full control | Full control | View |
4646
+---------------------------+----------------+------------------+------------------+----------------+------------------+------------------+
4747

48-
*Full control: View, Create, Update, Delete, Manage Permissions*
48+
*Full control: View, Create, Update, Delete, Manage Permissions.* *Note: Delete Service or Project is only allowed for the owner.*
4949

5050
**Use cases:**
5151

52-
- User with **Service Admin** permission can manage all aspects of the Service, including its Projects and associated objects.
53-
- User with **Project Admin** permission can manage the specific Project and its associated objects, but cannot see or modify other Projects under the same Service. They cannot even view the parents Service's details unless they have explicit permissions on that Service.
52+
- User with **Service Admin** permission can manage all aspects of the Service, including its Projects and associated objects, but only the owner can delete the Service or any Project.
53+
- User with **Project Admin** permission can manage the specific Project and its associated objects, but only the owner can delete the Service or any Project.
54+
Project Admin of Project cannot also see or modify other Projects under the same Service. They cannot even view the parents Service's details unless they have explicit permissions on that Service.
5455
- User with **Service Viewer** permission can only view all aspects of the Service, including its Projects and associated objects, without making any changes.
5556
- User with **Project Viewer** permission can only view the specific Project and its associated objects, but cannot see or modify other Projects under the same Service.
5657
- User with **Service Editor** permission can modify the Service, its associated objects and the Projects under that Service, but cannot delete those Projects. However, they still can delete any associated objects of those Projects (such as Exporters and Farms).

promgen/forms.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
from dateutil import parser
88
from django import forms
9-
from django.conf import settings
109
from django.contrib.auth.models import User
1110
from django.core.exceptions import ValidationError
1211
from django.utils.translation import gettext as _
@@ -264,7 +263,7 @@ def get_permission_choices(input_object):
264263

265264
def get_group_choices():
266265
yield ("", "")
267-
for g in models.Group.objects.exclude(name=settings.PROMGEN_DEFAULT_GROUP).order_by("name"):
266+
for g in models.Group.objects.order_by("name"):
268267
yield (g.name, g.name)
269268

270269

1.19 KB
Binary file not shown.

promgen/locale/ja/LC_MESSAGES/django.po

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -185,12 +185,6 @@ msgstr "Actions"
185185
msgid "No search parameters provided."
186186
msgstr "検索フレーズが指定されていません"
187187

188-
#: templates/promgen/service_detail.html:93
189-
#: templates/promgen/project_detail.html:101
190-
#: templates/promgen/farm_detail.html:71
191-
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."
192-
msgstr "このリリースは移行用のリリースです。ロールをメンバーに割り当てても、権限のチェックは行われません。次のリリースで権限のチェックが有効化されます。"
193-
194188
#: templates/promgen/permission_row.html:28
195189
msgid "Delete all permissions for user?"
196190
msgstr "ユーザーから全ての権限を削除しますか?"
@@ -337,3 +331,39 @@ msgstr "{from_user}は{to_user}に統合されました。"
337331
# : templates/admin/auth/user/change_list.html:7
338332
msgid "Merge user"
339333
msgstr "ユーザー統合"
334+
335+
#: proxy.py:284
336+
msgid "You must specify either a project or service label."
337+
msgstr "プロジェクトまたはサービスラベルを指定する必要があります。"
338+
339+
#: proxy.py:296
340+
msgid "You do not have permission to silence this alert."
341+
msgstr "このアラートを無効にする権限がありません。"
342+
343+
#: proxy.py:390
344+
msgid "You do not have permission to silence alerts for the following {label}: {names}."
345+
msgstr "次の{label}のアラートを無効にする権限がありません: {names}。"
346+
347+
#: proxy.py:400
348+
msgid "You do not have permission to silence alerts for many ({count}) {label}."
349+
msgstr "多くの({count}) {label}のアラートを無効にする権限がありません。"
350+
351+
#: errors.py:20
352+
msgid "Silence must include a service or project matcher."
353+
msgstr "サイレンスにはサービスまたはプロジェクトのマッチャーを含める必要があります。"
354+
355+
#: proxy.py:461
356+
msgid "You do not have permission to expire the silence that matches the following {label}: {names}."
357+
msgstr "次の{label}に一致するサイレンスを期限切れにする権限がありません: {names}。"
358+
359+
#: proxy.py:472
360+
msgid "You do not have permission to expire the silence that matches many ({count}) {label}."
361+
msgstr "多くの({count}) {label}に一致するサイレンスを期限切れにする権限がありません。"
362+
363+
#: views.py:398
364+
msgid "Only the service owner can delete the service."
365+
msgstr "サービスを削除できるのはサービスの所有者のみです。"
366+
367+
#: views.py:418
368+
msgid "Only the project or the service owner can delete the project."
369+
msgstr "プロジェクトを削除できるのはプロジェクトまたはサービスの所有者のみです。"

promgen/migrations/0003_default-group.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66

77
def create_group(apps, schema_editor):
8-
if not settings.PROMGEN_DEFAULT_GROUP:
8+
if not getattr(settings, "PROMGEN_DEFAULT_GROUP", None):
99
return
1010

1111
# Create Default Group
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Generated by Django 4.2.11 on 2025-08-14 08:34
2+
3+
from django.db import migrations
4+
5+
6+
def remove_group(apps, schema_editor):
7+
"""
8+
Remove the Default group and its associated permissions.
9+
10+
Remove the group name which is hardcoded as "Default" in the original Promgen.
11+
If the name is different in your application, you should change it
12+
according to the value used in settings.PROMGEN_DEFAULT_GROUP.
13+
"""
14+
default_group = apps.get_model("auth", "Group").objects.filter(name="Default").first()
15+
16+
if default_group:
17+
default_group.user_set.clear()
18+
default_group.permissions.clear()
19+
default_group.delete()
20+
21+
22+
class Migration(migrations.Migration):
23+
dependencies = [
24+
("promgen", "0042_alert-notification-instrumentation"),
25+
]
26+
27+
operations = [migrations.RunPython(remove_group)]

promgen/mixins.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
# Copyright (c) 2019 LINE Corporation
22
# These sources are released under the terms of the MIT license: see LICENSE
33

4+
import guardian.mixins
5+
import guardian.utils
46
from django.contrib import messages
57
from django.contrib.auth.mixins import PermissionRequiredMixin
68
from django.contrib.auth.views import redirect_to_login
79
from django.contrib.contenttypes.models import ContentType
8-
from django.shortcuts import get_object_or_404
10+
from django.http import HttpRequest
11+
from django.shortcuts import get_object_or_404, redirect
912
from django.views.generic.base import ContextMixin
1013
from django.views.generic.edit import FormView
1114

12-
from promgen import forms, models, notification
15+
from promgen import forms, models, notification, permissions, views
1316

1417

1518
class ContentTypeMixin:
@@ -96,3 +99,32 @@ def post(self, request, *args, **kwargs):
9699
else:
97100
return self.form_invalid(sender_form)
98101
return self.form_invalid(notifier_form)
102+
103+
104+
class PromgenGuardianPermissionMixin(guardian.mixins.PermissionRequiredMixin):
105+
def get_check_permission_object(self):
106+
# Override this method to return the object to check permissions for
107+
return self.get_object()
108+
109+
def check_permissions(self, request):
110+
# Always allow user to view the site rule
111+
if isinstance(self, views.RuleDetail) and isinstance(
112+
self.get_object().content_object, models.Site
113+
):
114+
return None
115+
116+
if not permissions.has_perm(
117+
request.user,
118+
self.get_required_permissions(request),
119+
self.get_check_permission_object(),
120+
):
121+
return self.on_perm_check_fail(request)
122+
123+
return None
124+
125+
def on_perm_check_fail(self, request: HttpRequest) -> None:
126+
messages.warning(request, "You do not have permission to perform this action.")
127+
referer = request.META.get("HTTP_REFERER")
128+
if referer and referer != request.build_absolute_uri():
129+
return redirect(referer)
130+
return redirect_to_login(self.request.get_full_path())

promgen/permissions.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
# Copyright (c) 2025 LINE Corporation
22
# These sources are released under the terms of the MIT license: see LICENSE
3+
from django.contrib.auth.models import User
4+
from django.db.models import Q
35
from django.utils.itercompat import is_iterable
6+
from guardian.shortcuts import get_objects_for_user
7+
from rest_framework import permissions
48
from rest_framework.permissions import BasePermission
59

10+
from promgen import models
11+
612

713
class PromgenModelPermissions(BasePermission):
814
"""
@@ -41,3 +47,153 @@ def has_permission(self, request, view):
4147
return any(request.user.has_perm(perm) for perm in perm_list)
4248
else:
4349
return all(request.user.has_perm(perm) for perm in perm_list)
50+
51+
52+
class ReadOnlyForAuthenticatedUserOrIsSuperuser(BasePermission):
53+
"""
54+
Customize Django REST Framework's base permission class to only allow read-only access for
55+
authenticated users and full access for superusers.
56+
"""
57+
58+
def has_permission(self, request, view):
59+
if request.user.is_superuser:
60+
return True
61+
return bool(
62+
request.user
63+
and request.user.is_authenticated
64+
and request.method in permissions.SAFE_METHODS
65+
)
66+
67+
68+
def get_check_permission_objects(obj):
69+
# Because we only define permission codes for Service, Group, and Project,
70+
# we need to map other objects to these.
71+
if isinstance(obj, (models.Service, models.Group)):
72+
return [obj]
73+
if isinstance(obj, models.Project):
74+
return [obj, obj.service]
75+
if isinstance(obj, (models.Exporter, models.URL, models.Farm)):
76+
return [obj.project, obj.project.service]
77+
if isinstance(obj, models.Host):
78+
return [obj.farm.project, obj.farm.project.service]
79+
if isinstance(obj, (models.Rule, models.Sender)):
80+
content_obj = getattr(obj, "content_object", None)
81+
if isinstance(content_obj, models.Project):
82+
return [content_obj, content_obj.service]
83+
elif content_obj is not None:
84+
return [content_obj]
85+
return None
86+
87+
88+
def has_perm(user: User, perms: list[str], obj) -> bool:
89+
# Superusers always have permission
90+
if user.is_active and user.is_superuser:
91+
return True
92+
93+
check_permission_objects = get_check_permission_objects(obj)
94+
if not check_permission_objects:
95+
return False
96+
97+
for check_obj in check_permission_objects:
98+
# If the check_obj is the user itself, return True.
99+
# Otherwise, check permissions.
100+
# This also returns True if the user belongs to a Group that has the permission.
101+
has_permission = user == check_obj or any(user.has_perm(perm, check_obj) for perm in perms)
102+
if has_permission:
103+
return True
104+
return False
105+
106+
107+
def get_objects_for_user_with_perms(user: User, perms: list[str], klass):
108+
"""
109+
Wrapper around guardian.shortcuts.get_objects_for_user to get objects
110+
for a user with specific permissions.
111+
112+
Some important parameters are set according to Promgen's permission model:
113+
- any_perm=True: Return objects that match any of the specified permissions.
114+
- use_groups=True: Consider permissions assigned via both user and group of users.
115+
- accept_global_perms=False: Do not consider global permissions for objects.
116+
117+
Args:
118+
user (User): The user for whom to retrieve objects.
119+
perms (list[str]): List of permission codenames to check.
120+
klass (Model, optional): The model class to filter objects.
121+
122+
Returns:
123+
QuerySet: A queryset of objects the user has the specified permissions for.
124+
125+
"""
126+
return get_objects_for_user(
127+
user,
128+
perms,
129+
any_perm=True,
130+
use_groups=True,
131+
accept_global_perms=False,
132+
klass=klass,
133+
)
134+
135+
136+
def get_accessible_services_for_user(user: User):
137+
return get_objects_for_user_with_perms(
138+
user, ["service_admin", "service_editor", "service_viewer"], klass=models.Service
139+
)
140+
141+
142+
def get_accessible_projects_for_user(user: User):
143+
services = get_accessible_services_for_user(user)
144+
projects = get_objects_for_user_with_perms(
145+
user, ["project_admin", "project_editor", "project_viewer"], klass=models.Project
146+
)
147+
return models.Project.objects.filter(Q(pk__in=projects) | Q(service__in=services))
148+
149+
150+
def get_accessible_groups_for_user(user: User):
151+
return get_objects_for_user_with_perms(
152+
user, ["group_admin", "group_member"], klass=models.Group
153+
)
154+
155+
156+
def get_editable_services_for_user(user: User):
157+
return get_objects_for_user_with_perms(
158+
user, ["service_admin", "service_editor"], klass=models.Service
159+
)
160+
161+
162+
def get_editable_projects_for_user(user: User):
163+
services = get_editable_services_for_user(user)
164+
projects = get_objects_for_user_with_perms(
165+
user, ["project_admin", "project_editor"], klass=models.Project
166+
)
167+
return models.Project.objects.filter(Q(pk__in=projects) | Q(service__in=services))
168+
169+
170+
def get_highest_role(user: User, obj):
171+
"""
172+
Determine the highest role a user has for a given object.
173+
174+
Roles are determined based on the following hierarchy:
175+
- ADMIN: service_admin, project_admin, group_admin
176+
- EDIT: service_editor, project_editor
177+
- VIEW: service_viewer, project_viewer, group_member
178+
"""
179+
180+
# Superusers always have ADMIN permission
181+
if user.is_active and user.is_superuser:
182+
return "ADMIN"
183+
184+
check_permission_objects = get_check_permission_objects(obj)
185+
if not check_permission_objects:
186+
return None
187+
188+
role_perms = [
189+
("ADMIN", ["service_admin", "project_admin", "group_admin"]),
190+
("EDIT", ["service_editor", "project_editor"]),
191+
("VIEW", ["service_viewer", "project_viewer", "group_member"]),
192+
]
193+
194+
for role, perms in role_perms:
195+
for check_obj in check_permission_objects:
196+
if any(user.has_perm(perm, check_obj) for perm in perms):
197+
return role
198+
199+
return None

0 commit comments

Comments
 (0)