Skip to content

Commit e6149fd

Browse files
committed
feat(management): fine-grained access control
Allow tighther control of what site admins can do based on their permissions. The previously too broad management.use only grants read-only access.
1 parent 3581478 commit e6149fd

14 files changed

Lines changed: 610 additions & 182 deletions

File tree

docs/changes.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ Weblate 2026.7
77

88
.. rubric:: Improvements
99

10+
* Management interface access control is now more fine-grained with dedicated site-wide permissions.
11+
1012
.. rubric:: Bug fixes
1113

1214
.. rubric:: Compatibility

docs/snippets/permissions.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,8 @@ List of privileges
334334
+-----------------------+-------------------------------------------+---------------------------------------+
335335
| Site wide privileges | Use management interface | |
336336
| +-------------------------------------------+---------------------------------------+
337+
| | Manage site configuration | |
338+
| +-------------------------------------------+---------------------------------------+
337339
| | Add new projects | :guilabel:`Add new projects` |
338340
| +-------------------------------------------+---------------------------------------+
339341
| | Add new workspaces | :guilabel:`Add new projects` |

weblate/accounts/views.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626
from django.core.exceptions import (
2727
ImproperlyConfigured,
2828
ObjectDoesNotExist,
29-
PermissionDenied,
3029
ValidationError,
3130
)
3231
from django.core.mail.message import EmailMessage
@@ -144,6 +143,7 @@
144143
remove_user,
145144
reset_api_token,
146145
)
146+
from weblate.auth.decorators import check_management_access
147147
from weblate.auth.forms import UserEditForm
148148
from weblate.auth.models import Invitation, User, get_anonymous
149149
from weblate.auth.utils import (
@@ -768,8 +768,7 @@ def handle_add_group(
768768
return HttpResponseRedirect(f"{self.get_success_url()}#groups")
769769

770770
def post(self, request: AuthenticatedHttpRequest, *args, **kwargs): # type: ignore[override]
771-
if not request.user.has_perm("user.edit"):
772-
raise PermissionDenied
771+
check_management_access(request, "user.edit")
773772
user = self.object = self.get_object()
774773
if "add_group" in request.POST:
775774
response = self.handle_add_group(request, user)

weblate/addons/tests.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
SphinxExtractPotForm,
4646
XgettextExtractPotForm,
4747
)
48+
from weblate.auth.models import Group, Permission, Role
4849
from weblate.lang.models import Language
4950
from weblate.trans.actions import ActionEvents
5051
from weblate.trans.file_format_params import get_default_params_for_file_format
@@ -7303,6 +7304,19 @@ class SiteWideAddonsTest(ViewTestCase):
73037304
def create_component(self):
73047305
return self.create_java()
73057306

7307+
def grant_global_permissions(self, *permissions: str) -> None:
7308+
role, _created = Role.objects.get_or_create(name="Test management role")
7309+
permission_objects = list(Permission.objects.filter(codename__in=permissions))
7310+
self.assertEqual(
7311+
{permission.codename for permission in permission_objects},
7312+
set(permissions),
7313+
)
7314+
role.permissions.add(*permission_objects)
7315+
group, _created = Group.objects.get_or_create(name="Test management team")
7316+
group.roles.add(role)
7317+
self.user.groups.add(group)
7318+
self.user.clear_cache()
7319+
73067320
def test_history_filters_sitewide_changes(self) -> None:
73077321
self.user.is_superuser = True
73087322
self.user.save()
@@ -7352,6 +7366,30 @@ def test_history_filters_sitewide_changes(self) -> None:
73527366
self.assertNotContains(response, component_target)
73537367
self.assertLessEqual(len(queries), 50, [query["sql"] for query in queries])
73547368

7369+
def test_sitewide_addon_detail_requires_management_access(self) -> None:
7370+
identifier = "weblate.addon.nonexisting"
7371+
Addon.objects.bulk_create([Addon(name=identifier)])
7372+
addon = Addon.objects.get(name=identifier)
7373+
detail_url = reverse("addon-detail", kwargs={"pk": addon.pk})
7374+
logs_url = reverse("addon-logs", kwargs={"pk": addon.pk})
7375+
7376+
self.grant_global_permissions("management.addons")
7377+
7378+
response = self.client.get(detail_url)
7379+
self.assertEqual(response.status_code, 403)
7380+
response = self.client.get(logs_url)
7381+
self.assertEqual(response.status_code, 403)
7382+
response = self.client.post(detail_url, {"delete": "1"})
7383+
self.assertEqual(response.status_code, 403)
7384+
self.assertTrue(Addon.objects.filter(pk=addon.pk).exists())
7385+
7386+
self.grant_global_permissions("management.use")
7387+
7388+
response = self.client.get(detail_url)
7389+
self.assertEqual(response.status_code, 200)
7390+
response = self.client.get(logs_url)
7391+
self.assertEqual(response.status_code, 200)
7392+
73557393
def test_gettext(self) -> None:
73567394
MsgmergeAddon.create()
73577395
# This is not needed in real life as installation will happen

weblate/addons/views.py

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from django.views.generic import DetailView, ListView, UpdateView
1414

1515
from weblate.addons.models import ADDONS, Addon
16+
from weblate.auth.decorators import check_management_access
1617
from weblate.trans.models import Category, Change, Component, Project
1718
from weblate.utils import messages
1819
from weblate.utils.views import PathViewMixin, get_paginator
@@ -48,9 +49,7 @@ def get_queryset(self):
4849
self.kwargs["project_obj"] = self.path_object
4950
return Addon.objects.filter_project(self.path_object)
5051

51-
if not self.request.user.has_perm("management.addons"):
52-
msg = "Can not manage add-ons"
53-
raise PermissionDenied(msg)
52+
check_management_access(self.request, "management.addons")
5453
return Addon.objects.filter_sitewide()
5554

5655
def get_success_url(self):
@@ -229,14 +228,8 @@ def get_object(self): # type: ignore[override]
229228
if obj.project and not self.request.user.has_perm("project.edit", obj.project):
230229
msg = "Can not edit project"
231230
raise PermissionDenied(msg)
232-
if (
233-
obj.project is None
234-
and obj.category is None
235-
and obj.component is None
236-
and not self.request.user.has_perm("management.addons")
237-
):
238-
msg = "Can not manage add-ons"
239-
raise PermissionDenied(msg)
231+
if obj.project is None and obj.category is None and obj.component is None:
232+
check_management_access(self.request, "management.addons")
240233
return obj
241234

242235

weblate/auth/data.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@
130130
# Translators: Permission name
131131
("management.use", gettext_noop("Use management interface")),
132132
# Translators: Permission name
133+
("management.configure", gettext_noop("Manage site configuration")),
134+
# Translators: Permission name
133135
("project.add", gettext_noop("Add new projects")),
134136
# Translators: Permission name
135137
("workspace.add", gettext_noop("Add new workspaces")),

weblate/auth/decorators.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,41 @@
1010
from django.core.exceptions import PermissionDenied
1111

1212
if TYPE_CHECKING:
13+
from collections.abc import Callable
14+
1315
from weblate.auth.models import AuthenticatedHttpRequest
1416

1517

16-
def management_access(view):
18+
def check_management_access(
19+
request: AuthenticatedHttpRequest, permission: str | None = None
20+
) -> None:
21+
"""Check management interface access and optional site-wide permission."""
22+
if not request.user.has_perm("management.use"):
23+
raise PermissionDenied
24+
if permission is not None and not request.user.has_perm(permission):
25+
raise PermissionDenied
26+
27+
28+
def management_access(view: Callable):
1729
"""Check management access decorator."""
1830

1931
@wraps(view)
2032
def wrapper(request: AuthenticatedHttpRequest, *args, **kwargs):
21-
if not request.user.has_perm("management.use"):
22-
raise PermissionDenied
33+
check_management_access(request)
2334
return view(request, *args, **kwargs)
2435

2536
return wrapper
37+
38+
39+
def management_permission_required(permission: str):
40+
"""Check management access and a specific site-wide permission."""
41+
42+
def decorator(view: Callable):
43+
@wraps(view)
44+
def wrapper(request: AuthenticatedHttpRequest, *args, **kwargs):
45+
check_management_access(request, permission)
46+
return view(request, *args, **kwargs)
47+
48+
return wrapper
49+
50+
return decorator

weblate/auth/views.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from django.utils.translation import gettext
1616
from django.views.generic import DetailView, UpdateView
1717

18+
from weblate.auth.decorators import check_management_access
1819
from weblate.auth.forms import ProjectTeamForm, SitewideTeamForm, WorkspaceTeamForm
1920
from weblate.auth.models import (
2021
AutoGroup,
@@ -63,6 +64,11 @@ def get_form_class(self):
6364
def get_form(self, form_class=None):
6465
if not self.request.user.has_perm("meta:team.edit", self.object):
6566
return None
67+
if (
68+
self.object.defining_project is None
69+
and self.object.defining_workspace is None
70+
):
71+
check_management_access(self.request, "group.edit")
6672
return super().get_form(form_class)
6773

6874
def get_form_kwargs(self):
@@ -165,6 +171,12 @@ def handle_delete(self, request: AuthenticatedHttpRequest):
165171
# pylint: disable=arguments-differ
166172
def post(self, request: AuthenticatedHttpRequest, **kwargs):
167173
self.object = self.get_object()
174+
if (
175+
self.object.defining_project is None
176+
and self.object.defining_workspace is None
177+
and request.user.has_perm("group.edit")
178+
):
179+
check_management_access(request, "group.edit")
168180
if self.request.user.has_perm("meta:team.users", self.object):
169181
if "add_user" in request.POST:
170182
return self.handle_add_user(request)
@@ -253,7 +265,8 @@ def post(self, request: AuthenticatedHttpRequest, **kwargs) -> HttpResponse:
253265
elif workspace:
254266
allowed = user.has_perm("workspace.edit_members", workspace)
255267
else:
256-
allowed = user.has_perm("user.edit")
268+
check_management_access(request, "user.edit")
269+
allowed = True
257270
if not allowed:
258271
raise PermissionDenied
259272

weblate/templates/manage/teams.html

Lines changed: 64 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ <h4 class="card-title">{% translate "Manage teams" %}</h4>
2626
<th>{% translate "Projects" %}</th>
2727
<th>{% translate "Components" %}</th>
2828
<th>{% translate "Members" %}</th>
29-
<th></th>
29+
{% if can_edit_teams %}<th></th>{% endif %}
3030
</tr>
3131
</thead>
3232
<tbody>
@@ -38,75 +38,79 @@ <h4 class="card-title">{% translate "Manage teams" %}</h4>
3838
<td>{% include "auth/teams-projects.html" %}</td>
3939
<td>{% include "auth/teams-components.html" %}</td>
4040
<td class="number">{% include "auth/teams-count.html" %}</td>
41-
<td>
42-
<a href="{{ group.get_absolute_url }}"
43-
class="btn btn-link btn-xs"
44-
title="{% translate "Edit" %}">{% icon 'pencil.svg' %}</a>
45-
{% if not group.internal %}
46-
<a href="#"
47-
data-bs-toggle="modal"
48-
data-bs-target="#delete_group_{{ group.id }}"
49-
class="btn btn-link btn-xs red"
50-
title="{% translate "Delete" %}">{% icon 'delete.svg' %}</a>
51-
{% endif %}
52-
<form action="{{ group.get_absolute_url }}" method="post" class="inlineform">
53-
{% csrf_token %}
54-
<input type="hidden" name="next" value="{{ request.get_full_path }}" />
55-
<div class="modal fade"
56-
tabindex="-1"
57-
role="dialog"
58-
aria-labelledby="delete_group_title_{{ group.id }}"
59-
aria-describedby="delete_group_body_{{ group.id }}"
60-
id="delete_group_{{ group.id }}">
61-
<div class="modal-dialog" role="document">
62-
<div class="modal-content">
63-
<div class="modal-header">
64-
<h4 class="modal-title" id="delete_group_title_{{ group.id }}">{% translate "Are you absolutely sure?" %}</h4>
65-
<button type="button"
66-
class="btn-close"
67-
data-bs-dismiss="modal"
68-
aria-label="{% translate "Close" %}">
69-
<span aria-hidden="true">×</span>
70-
</button>
71-
</div>
72-
<div class="modal-body" id="delete_group_body_{{ group.id }}">
73-
{% blocktranslate with name=group %}This will delete the group <b>{{ name }}</b>.{% endblocktranslate %}
74-
{% if group.user__count %}
75-
{% blocktranslate count count=group.user__count %}There is {{ count }} member of this group. Deleting the group might affect their access.{% plural %}There are {{ count }} members of this group. Deleting the group might affect their access.{% endblocktranslate %}
76-
{% endif %}
77-
</div>
78-
<div class="modal-footer">
79-
<input type="submit"
80-
class="btn btn-danger"
81-
name="delete"
82-
value="{% translate "Delete" %}" />
41+
{% if can_edit_teams %}
42+
<td>
43+
<a href="{{ group.get_absolute_url }}"
44+
class="btn btn-link btn-xs"
45+
title="{% translate "Edit" %}">{% icon 'pencil.svg' %}</a>
46+
{% if not group.internal %}
47+
<a href="#"
48+
data-bs-toggle="modal"
49+
data-bs-target="#delete_group_{{ group.id }}"
50+
class="btn btn-link btn-xs red"
51+
title="{% translate "Delete" %}">{% icon 'delete.svg' %}</a>
52+
{% endif %}
53+
<form action="{{ group.get_absolute_url }}" method="post" class="inlineform">
54+
{% csrf_token %}
55+
<input type="hidden" name="next" value="{{ request.get_full_path }}" />
56+
<div class="modal fade"
57+
tabindex="-1"
58+
role="dialog"
59+
aria-labelledby="delete_group_title_{{ group.id }}"
60+
aria-describedby="delete_group_body_{{ group.id }}"
61+
id="delete_group_{{ group.id }}">
62+
<div class="modal-dialog" role="document">
63+
<div class="modal-content">
64+
<div class="modal-header">
65+
<h4 class="modal-title" id="delete_group_title_{{ group.id }}">{% translate "Are you absolutely sure?" %}</h4>
66+
<button type="button"
67+
class="btn-close"
68+
data-bs-dismiss="modal"
69+
aria-label="{% translate "Close" %}">
70+
<span aria-hidden="true">×</span>
71+
</button>
72+
</div>
73+
<div class="modal-body" id="delete_group_body_{{ group.id }}">
74+
{% blocktranslate with name=group %}This will delete the group <b>{{ name }}</b>.{% endblocktranslate %}
75+
{% if group.user__count %}
76+
{% blocktranslate count count=group.user__count %}There is {{ count }} member of this group. Deleting the group might affect their access.{% plural %}There are {{ count }} members of this group. Deleting the group might affect their access.{% endblocktranslate %}
77+
{% endif %}
78+
</div>
79+
<div class="modal-footer">
80+
<input type="submit"
81+
class="btn btn-danger"
82+
name="delete"
83+
value="{% translate "Delete" %}" />
84+
</div>
8385
</div>
86+
<!-- /.modal-content -->
8487
</div>
85-
<!-- /.modal-content -->
88+
<!-- /.modal-dialog -->
8689
</div>
87-
<!-- /.modal-dialog -->
88-
</div>
89-
<!-- /.modal -->
90-
</form>
91-
</td>
90+
<!-- /.modal -->
91+
</form>
92+
</td>
93+
{% endif %}
9294
</tr>
9395
{% endfor %}
9496
</tbody>
9597
</table>
9698
<div class="card-footer">{% include "paginator.html" %}</div>
9799
</div>
98100

99-
<form method="post">
100-
{% csrf_token %}
101-
<div class="card">
102-
<div class="card-header">
103-
<h4 class="card-title">{% translate "Create new team" %}</h4>
101+
{% if can_edit_teams %}
102+
<form method="post">
103+
{% csrf_token %}
104+
<div class="card">
105+
<div class="card-header">
106+
<h4 class="card-title">{% translate "Create new team" %}</h4>
107+
</div>
108+
<div class="card-body">{{ form|crispy }}</div>
109+
<div class="card-footer">
110+
<input type="submit" value="{% translate "Save" %}" class="btn btn-primary" />
111+
</div>
104112
</div>
105-
<div class="card-body">{{ form|crispy }}</div>
106-
<div class="card-footer">
107-
<input type="submit" value="{% translate "Save" %}" class="btn btn-primary" />
108-
</div>
109-
</div>
110-
</form>
113+
</form>
114+
{% endif %}
111115

112116
{% endblock content %}

0 commit comments

Comments
 (0)