Commit 82eb778f authored by Michal Čihař's avatar Michal Čihař

Merge pull request #963 from matejcik/group-acl

Group-based ACLs for per-language and per-subproject permissions
parents 50c68cb0 6a4d3227
......@@ -3,7 +3,7 @@
{% load translations %}
{% block form_top %}
{% if opts.model_name == 'project' or opts.model_name == 'subproject' or opts.model_name == 'advertisement' or opts.model_name == 'whiteboardmessage' %}
{% if opts.model_name == 'project' or opts.model_name == 'subproject' or opts.model_name == 'advertisement' or opts.model_name == 'whiteboardmessage' or opts.model_name == 'groupacl' %}
{% if opts.model_name == 'advertisement' %}
{% doc_url 'admin/advertisement' as url %}
{% else %}
......
......@@ -24,7 +24,7 @@ from django.utils.translation import ugettext_lazy as _
from weblate.trans.models import (
Project, SubProject, Translation, Advertisement,
Unit, Suggestion, Comment, Check, Dictionary, Change,
Source, WhiteboardMessage
Source, WhiteboardMessage, GroupACL
)
......@@ -239,11 +239,30 @@ class SourceAdmin(admin.ModelAdmin):
date_hierarchy = 'timestamp'
class GroupACLAdmin(admin.ModelAdmin):
list_display = ['language', 'project_subproject', 'group_list']
def group_list(self, obj):
groups = obj.groups.values_list('name', flat=True)
ret = ', '.join(groups[:5])
if len(groups) > 5:
ret += ', ...'
return ret
def project_subproject(self, obj):
if obj.subproject:
return obj.subproject
else:
return obj.project
project_subproject.short_description = _('Project / Subproject')
# Register in admin interface
admin.site.register(Project, ProjectAdmin)
admin.site.register(SubProject, SubProjectAdmin)
admin.site.register(Advertisement, AdvertisementAdmin)
admin.site.register(WhiteboardMessage, WhiteboardAdmin)
admin.site.register(GroupACL, GroupACLAdmin)
# Show some controls only in debug mode
if settings.DEBUG:
......
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('lang', '0002_auto_20150630_1208'),
('trans', '0051_auto_20151222_1059'),
]
operations = [
migrations.CreateModel(
name='GroupACL',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('groups', models.ManyToManyField(to='auth.Group')),
('language', models.ForeignKey(blank=True, to='lang.Language', null=True)),
('project', models.ForeignKey(blank=True, to='trans.Project', null=True)),
('subproject', models.ForeignKey(blank=True, to='trans.SubProject', null=True)),
],
options={
'verbose_name': 'Group ACL',
'verbose_name_plural': 'Group ACLs',
},
),
migrations.AlterUniqueTogether(
name='groupacl',
unique_together=set([('project', 'subproject', 'language')]),
),
]
......@@ -34,6 +34,7 @@ from weblate.trans.models.unitdata import (
from weblate.trans.models.search import IndexUpdate
from weblate.trans.models.changes import Change
from weblate.trans.models.dictionary import Dictionary
from weblate.trans.models.group_acl import GroupACL
from weblate.trans.models.source import Source
from weblate.trans.models.advertisement import Advertisement
from weblate.trans.models.whiteboard import WhiteboardMessage
......@@ -50,7 +51,7 @@ from weblate.trans.scripts import (
__all__ = [
'Project', 'SubProject', 'Translation', 'Unit', 'Check', 'Suggestion',
'Comment', 'Vote', 'IndexUpdate', 'Change', 'Dictionary', 'Source',
'Advertisement', 'WhiteboardMessage',
'Advertisement', 'WhiteboardMessage', 'GroupACL',
]
......
# -*- coding: utf-8 -*-
#
# Copyright © 2012 - 2015 Michal Čihař <michal@cihar.com>
#
# This file is part of Weblate <https://weblate.org/>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
"""Whiteboard model."""
from django.db import models
from django.core.exceptions import ValidationError
from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _
from django.contrib.auth.models import Group
from weblate.lang.models import Language
@python_2_unicode_compatible
class GroupACL(models.Model):
groups = models.ManyToManyField(Group)
# avoid importing Project and SubProject because of circular dependency
project = models.ForeignKey('Project', null=True, blank=True)
subproject = models.ForeignKey('SubProject', null=True, blank=True)
language = models.ForeignKey(Language, null=True, blank=True)
def clean(self):
if not self.project and not self.subproject and not self.language:
raise ValidationError(
_('Project, subproject or language must be specified')
)
# ignore project if subproject is set
if self.project and self.subproject:
self.project = None
def __str__(self):
params = []
if self.language:
params.append("language={}".format(self.language))
if self.subproject:
params.append("project={}".format(self.subproject))
elif self.project:
params.append("project={}".format(self.project))
if not params:
# in case the object is not valid
params.append("(unspecified)")
return "<GroupACL({}) for {}>".format(self.pk, ", ".join(params))
class Meta(object):
unique_together = ('project', 'subproject', 'language')
verbose_name = _('Group ACL')
verbose_name_plural = _('Group ACLs')
......@@ -20,10 +20,11 @@
"""
Permissions abstract layer for Weblate.
"""
from django.contrib.auth.models import Group, User
from django.db.models import Q
from django.contrib.auth.models import Group, User, Permission
from weblate import appsettings
from weblate.trans.models.group_acl import GroupACL
def check_owner(user, project, permission):
......@@ -41,6 +42,37 @@ def check_owner(user, project, permission):
).exists()
def has_group_perm(user, translation, permission):
"""
Checks whether GroupACL rules allow user to have
given permission.
"""
acls = list(GroupACL.objects.filter(
Q(language=translation.language) |
Q(project=translation.subproject.project) |
Q(subproject=translation.subproject)))
if not acls:
return user.has_perm(permission)
# more specific rules are more important: subproject > project > language
acls.sort(reverse=True, key=lambda a: (
a.subproject is not None,
a.project is not None,
a.language is not None))
membership = acls[0].groups.all() & user.groups.all()
if not membership.exists():
return False
app, perm = permission.split('.')
return Permission.objects.filter(
group__in=membership,
content_type__app_label=app,
codename=perm
).exists()
def check_permission(user, project, permission):
"""
Generic check for permission with owner fallback.
......@@ -82,13 +114,14 @@ def can_edit(user, translation, permission):
return False
if check_owner(user, translation.subproject.project, permission):
return True
if not user.has_perm(permission):
if not has_group_perm(user, translation, permission):
return False
if translation.is_template() and not user.has_perm('trans.save_template'):
if translation.is_template() \
and not has_group_perm(user, translation, 'trans.save_template'):
return False
if (translation.subproject.suggestion_voting and
translation.subproject.suggestion_autoaccept > 0 and
not user.has_perm('trans.override_suggestion')):
if (not has_group_perm(user, translation, 'trans.override_suggestion') and
translation.subproject.suggestion_voting and
translation.subproject.suggestion_autoaccept > 0):
return False
return True
......@@ -141,9 +174,10 @@ def can_vote_suggestion(user, translation):
project = translation.subproject.project
if check_owner(user, project, 'trans.vote_suggestion'):
return True
if not user.has_perm('trans.vote_suggestion'):
if not has_group_perm(user, translation, 'trans.vote_suggestion'):
return False
if translation.is_template() and not user.has_perm('trans.save_template'):
if translation.is_template() \
and not has_group_perm(user, translation, 'trans.save_template'):
return False
return True
......@@ -155,7 +189,7 @@ def can_use_mt(user, translation):
"""
if not appsettings.MACHINE_TRANSLATION_ENABLED:
return False
if not user.has_perm('trans.use_mt'):
if not has_group_perm(user, translation, 'trans.use_mt'):
return False
if check_owner(user, translation.subproject.project, 'trans.use_mt'):
return True
......
......@@ -937,7 +937,7 @@ class WhiteboardMessageTest(TestCase):
class ModelTestCase(RepoTestCase):
def setUp(self):
super(ModelTestCase, self).setUp()
self.create_subproject()
self.subproject = self.create_subproject()
class SourceTest(ModelTestCase):
......
......@@ -18,12 +18,16 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from django.contrib.auth.models import User, Group
from django.contrib.auth.models import User, Group, Permission
from django.test import TestCase
from weblate.trans.models import Project
from weblate.lang.models import Language
from weblate.trans.models import (
GroupACL, Project, Translation
)
from weblate.trans.permissions import (
check_owner, check_permission, can_delete_comment
check_owner, check_permission, can_delete_comment, can_edit
)
from weblate.trans.tests.test_models import ModelTestCase
class PermissionsTest(TestCase):
......@@ -76,3 +80,54 @@ class PermissionsTest(TestCase):
self.assertFalse(self.project.permissions_cache[key])
self.project.permissions_cache[key] = True
self.assertTrue(can_delete_comment(self.user, self.project))
class GroupACLTest(ModelTestCase):
PERMISSION = "trans.save_translation"
def setUp(self):
super(GroupACLTest, self).setUp()
self.user = User.objects.create(username="user")
self.privileged = User.objects.create(username="privileged")
self.group = Group.objects.create(name="testgroup")
self.project = self.subproject.project
self.language = Language.objects.get_default()
self.trans = Translation.objects.create(
subproject=self.subproject, language=self.language,
filename="this/is/not/a.template"
)
app, perm = self.PERMISSION.split('.')
self.permission = Permission.objects.get(
codename=perm, content_type__app_label=app
)
self.group.permissions.add(self.permission)
self.privileged.groups.add(self.group)
def test_acl_lockout(self):
self.assertTrue(can_edit(self.user, self.trans, self.PERMISSION))
self.assertTrue(can_edit(self.privileged, self.trans, self.PERMISSION))
acl = GroupACL.objects.create(subproject=self.subproject)
acl.groups.add(self.group)
self.assertTrue(can_edit(self.privileged, self.trans, self.PERMISSION))
self.assertFalse(can_edit(self.user, self.trans, self.PERMISSION))
def test_acl_overlap(self):
acl_lang = GroupACL.objects.create(language=self.language)
acl_lang.groups.add(self.group)
self.assertTrue(
can_edit(self.privileged, self.trans, self.PERMISSION))
acl_sub = GroupACL.objects.create(subproject=self.subproject)
self.assertFalse(
can_edit(self.privileged, self.trans, self.PERMISSION))
acl_sub.groups.add(self.group)
self.assertTrue(
can_edit(self.privileged, self.trans, self.PERMISSION))
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment