Skip to content

Commit 8f89341

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 7262be6 commit 8f89341

10 files changed

Lines changed: 459 additions & 167 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/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/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 %}

weblate/templates/manage/tools.html

Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,23 @@
1414
{% block content %}
1515

1616

17-
<form method="post">
18-
{% csrf_token %}
19-
<div class="card">
20-
<div class="card-header">
21-
<h4 class="card-title">
22-
{% documentation_icon 'admin/announcements' right=True %}
23-
{% translate "Post announcement" %}
24-
</h4>
25-
</div>
26-
<div class="card-body">{{ announce_form|crispy }}</div>
27-
<div class="card-footer">
28-
<input type="submit" class="btn btn-primary" value="{% translate "Send" %}" />
17+
{% if can_post_announcement %}
18+
<form method="post">
19+
{% csrf_token %}
20+
<div class="card">
21+
<div class="card-header">
22+
<h4 class="card-title">
23+
{% documentation_icon 'admin/announcements' right=True %}
24+
{% translate "Post announcement" %}
25+
</h4>
26+
</div>
27+
<div class="card-body">{{ announce_form|crispy }}</div>
28+
<div class="card-footer">
29+
<input type="submit" class="btn btn-primary" value="{% translate "Send" %}" />
30+
</div>
2931
</div>
30-
</div>
31-
</form>
32+
</form>
33+
{% endif %}
3234

3335

3436
<div class="card">
@@ -43,20 +45,22 @@ <h4 class="card-title">{% translate "Django admin interface" %}</h4>
4345
</div>
4446
</div>
4547

46-
<form method="post">
47-
{% csrf_token %}
48-
<div class="card">
49-
<div class="card-header">
50-
<h4 class="card-title">{% translate "Send test e-mail" %}</h4>
51-
</div>
52-
<div class="card-body">{{ email_form|crispy }}</div>
53-
<div class="card-footer">
54-
<input type="submit" class="btn btn-primary" value="{% translate "Send" %}" />
48+
{% if can_configure %}
49+
<form method="post">
50+
{% csrf_token %}
51+
<div class="card">
52+
<div class="card-header">
53+
<h4 class="card-title">{% translate "Send test e-mail" %}</h4>
54+
</div>
55+
<div class="card-body">{{ email_form|crispy }}</div>
56+
<div class="card-footer">
57+
<input type="submit" class="btn btn-primary" value="{% translate "Send" %}" />
58+
</div>
5559
</div>
56-
</div>
57-
</form>
60+
</form>
61+
{% endif %}
5862

59-
{% if has_sentry %}
63+
{% if can_configure and has_sentry %}
6064
<form method="post">
6165
{% csrf_token %}
6266
<div class="card">

0 commit comments

Comments
 (0)