Commit 5a2e7dbc authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Merge branch '152-permissions' into 'develop'

Resolve "Permission management overhaul"

Closes #152

See merge request funkwhale/funkwhale!201
parents a67e5789 aee792ab
from django.core.management.base import BaseCommand, CommandError
from funkwhale_api.common import scripts
class Command(BaseCommand):
help = 'Run a specific script from funkwhale_api/common/scripts/'
def add_arguments(self, parser):
parser.add_argument('script_name', nargs='?', type=str)
parser.add_argument(
'--noinput', '--no-input', action='store_false', dest='interactive',
help="Do NOT prompt the user for input of any kind.",
)
def handle(self, *args, **options):
name = options['script_name']
if not name:
self.show_help()
available_scripts = self.get_scripts()
try:
script = available_scripts[name]
except KeyError:
raise CommandError(
'{} is not a valid script. Run python manage.py script for a '
'list of available scripts'.format(name))
self.stdout.write('')
if options['interactive']:
message = (
'Are you sure you want to execute the script {}?\n\n'
"Type 'yes' to continue, or 'no' to cancel: "
).format(name)
if input(''.join(message)) != 'yes':
raise CommandError("Script cancelled.")
script['entrypoint'](self, **options)
def show_help(self):
indentation = 4
self.stdout.write('')
self.stdout.write('Available scripts:')
self.stdout.write('Launch with: python manage.py <script_name>')
available_scripts = self.get_scripts()
for name, script in sorted(available_scripts.items()):
self.stdout.write('')
self.stdout.write(self.style.SUCCESS(name))
self.stdout.write('')
for line in script['help'].splitlines():
self.stdout.write('     {}'.format(line))
self.stdout.write('')
def get_scripts(self):
available_scripts = [
k for k in sorted(scripts.__dict__.keys())
if not k.startswith('__')
]
data = {}
for name in available_scripts:
module = getattr(scripts, name)
data[name] = {
'name': name,
'help': module.__doc__.strip(),
'entrypoint': module.main
}
return data
......@@ -3,7 +3,7 @@ import operator
from django.conf import settings
from django.http import Http404
from rest_framework.permissions import BasePermission, DjangoModelPermissions
from rest_framework.permissions import BasePermission
from funkwhale_api.common import preferences
......@@ -16,17 +16,6 @@ class ConditionalAuthentication(BasePermission):
return True
class HasModelPermission(DjangoModelPermissions):
"""
Same as DjangoModelPermissions, but we pin the model:
class MyModelPermission(HasModelPermission):
model = User
"""
def get_required_permissions(self, method, model_cls):
return super().get_required_permissions(method, self.model)
class OwnerPermission(BasePermission):
"""
Ensure the request user is the owner of the object.
......
from . import django_permissions_to_user_permissions
from . import test
"""
Convert django permissions to user permissions in the database,
following the work done in #152.
"""
from django.db.models import Q
from funkwhale_api.users import models
from django.contrib.auth.models import Permission
mapping = {
'dynamic_preferences.change_globalpreferencemodel': 'settings',
'music.add_importbatch': 'library',
'federation.change_library': 'federation',
}
def main(command, **kwargs):
for codename, user_permission in sorted(mapping.items()):
app_label, c = codename.split('.')
p = Permission.objects.get(
content_type__app_label=app_label, codename=c)
users = models.User.objects.filter(
Q(groups__permissions=p) | Q(user_permissions=p)).distinct()
total = users.count()
command.stdout.write('Updating {} users with {} permission...'.format(
total, user_permission
))
users.update(**{'permission_{}'.format(user_permission): True})
"""
This is a test script that does nothing.
You can launch it just to check how it works.
"""
def main(command, **kwargs):
command.stdout.write('Test script run successfully')
......@@ -15,8 +15,8 @@ from rest_framework.serializers import ValidationError
from funkwhale_api.common import preferences
from funkwhale_api.common import utils as funkwhale_utils
from funkwhale_api.common.permissions import HasModelPermission
from funkwhale_api.music.models import TrackFile
from funkwhale_api.users.permissions import HasUserPermission
from . import activity
from . import actors
......@@ -187,16 +187,13 @@ class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet):
return response.Response(data)
class LibraryPermission(HasModelPermission):
model = models.Library
class LibraryViewSet(
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet):
permission_classes = [LibraryPermission]
permission_classes = (HasUserPermission,)
required_permissions = ['federation']
queryset = models.Library.objects.all().select_related(
'actor',
'follow',
......@@ -291,7 +288,8 @@ class LibraryViewSet(
class LibraryTrackViewSet(
mixins.ListModelMixin,
viewsets.GenericViewSet):
permission_classes = [LibraryPermission]
permission_classes = (HasUserPermission,)
required_permissions = ['federation']
queryset = models.LibraryTrack.objects.all().select_related(
'library__actor',
'library__follow',
......
......@@ -6,6 +6,7 @@ from dynamic_preferences.api import viewsets as preferences_viewsets
from dynamic_preferences.registries import global_preferences_registry
from funkwhale_api.common import preferences
from funkwhale_api.users.permissions import HasUserPermission
from . import nodeinfo
from . import stats
......@@ -18,7 +19,8 @@ NODEINFO_2_CONTENT_TYPE = (
class AdminSettings(preferences_viewsets.GlobalPreferencesViewSet):
pagination_class = None
permission_classes = (HasUserPermission,)
required_permissions = ['settings']
class InstanceSettings(views.APIView):
permission_classes = []
......
......@@ -25,8 +25,8 @@ from rest_framework import permissions
from musicbrainzngs import ResponseError
from funkwhale_api.common import utils as funkwhale_utils
from funkwhale_api.common.permissions import (
ConditionalAuthentication, HasModelPermission)
from funkwhale_api.common.permissions import ConditionalAuthentication
from funkwhale_api.users.permissions import HasUserPermission
from taggit.models import Tag
from funkwhale_api.federation import actors
from funkwhale_api.federation.authentication import SignatureAuthentication
......@@ -107,25 +107,22 @@ class ImportBatchViewSet(
.annotate(job_count=Count('jobs'))
)
serializer_class = serializers.ImportBatchSerializer
permission_classes = (permissions.DjangoModelPermissions, )
permission_classes = (HasUserPermission,)
required_permissions = ['library']
filter_class = filters.ImportBatchFilter
def perform_create(self, serializer):
serializer.save(submitted_by=self.request.user)
class ImportJobPermission(HasModelPermission):
# not a typo, perms on import job is proxied to import batch
model = models.ImportBatch
class ImportJobViewSet(
mixins.CreateModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet):
queryset = (models.ImportJob.objects.all().select_related())
serializer_class = serializers.ImportJobSerializer
permission_classes = (ImportJobPermission, )
permission_classes = (HasUserPermission,)
required_permissions = ['library']
filter_class = filters.ImportJobFilter
@list_route(methods=['get'])
......@@ -442,7 +439,8 @@ class Search(views.APIView):
class SubmitViewSet(viewsets.ViewSet):
queryset = models.ImportBatch.objects.none()
permission_classes = (permissions.DjangoModelPermissions, )
permission_classes = (HasUserPermission,)
required_permissions = ['library']
@list_route(methods=['post'])
@transaction.non_atomic_requests
......
......@@ -5,6 +5,7 @@ from django import forms
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as AuthUserAdmin
from django.contrib.auth.forms import UserChangeForm, UserCreationForm
from django.utils.translation import ugettext_lazy as _
from .models import User
......@@ -41,8 +42,33 @@ class UserAdmin(AuthUserAdmin):
'email',
'date_joined',
'last_login',
'privacy_level',
'is_staff',
'is_superuser',
]
list_filter = [
'is_superuser',
'is_staff',
'privacy_level',
'permission_settings',
'permission_library',
'permission_federation',
]
fieldsets = (
(None, {'fields': ('username', 'password', 'privacy_level')}),
(_('Personal info'), {'fields': ('first_name', 'last_name', 'email')}),
(_('Permissions'), {
'fields': (
'is_active',
'is_staff',
'is_superuser',
'permission_library',
'permission_settings',
'permission_federation')}),
(_('Important dates'), {'fields': ('last_login', 'date_joined')}),
(_('Useless fields'), {
'fields': (
'user_permissions',
'groups',
)})
)
import factory
from funkwhale_api.factories import registry
from funkwhale_api.factories import registry, ManyToManyFromList
from django.contrib.auth.models import Permission
@registry.register
class GroupFactory(factory.django.DjangoModelFactory):
name = factory.Sequence(lambda n: 'group-{0}'.format(n))
class Meta:
model = 'auth.Group'
@factory.post_generation
def perms(self, create, extracted, **kwargs):
if not create:
# Simple build, do nothing.
return
if extracted:
perms = [
Permission.objects.get(
content_type__app_label=p.split('.')[0],
codename=p.split('.')[1],
)
for p in extracted
]
# A list of permissions were passed in, use them
self.permissions.add(*perms)
@registry.register
class UserFactory(factory.django.DjangoModelFactory):
username = factory.Sequence(lambda n: 'user-{0}'.format(n))
email = factory.Sequence(lambda n: 'user-{0}@example.com'.format(n))
password = factory.PostGenerationMethodCall('set_password', 'test')
subsonic_api_token = None
groups = ManyToManyFromList('groups')
class Meta:
model = 'users.User'
......
# Generated by Django 2.0.4 on 2018-05-17 23:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0005_user_subsonic_api_token'),
]
operations = [
migrations.AddField(
model_name='user',
name='permission_federation',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='user',
name='permission_library',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='user',
name='permission_settings',
field=models.BooleanField(default=False),
),
]
......@@ -19,6 +19,13 @@ def get_token():
return binascii.b2a_hex(os.urandom(15)).decode('utf-8')
PERMISSIONS = [
'federation',
'library',
'settings',
]
@python_2_unicode_compatible
class User(AbstractUser):
......@@ -28,20 +35,6 @@ class User(AbstractUser):
# updated on logout or password change, to invalidate JWT
secret_key = models.UUIDField(default=uuid.uuid4, null=True)
# permissions that are used for API access and that worth serializing
relevant_permissions = {
# internal_codename : {external_codename}
'music.add_importbatch': {
'external_codename': 'import.launch',
},
'dynamic_preferences.change_globalpreferencemodel': {
'external_codename': 'settings.change',
},
'federation.change_library': {
'external_codename': 'federation.manage',
},
}
privacy_level = fields.get_privacy_field()
# Unfortunately, Subsonic API assumes a MD5/password authentication
......@@ -52,12 +45,32 @@ class User(AbstractUser):
subsonic_api_token = models.CharField(
blank=True, null=True, max_length=255)
# permissions
permission_federation = models.BooleanField(
'Manage library federation',
help_text='Follow other instances, accept/deny library follow requests...',
default=False)
permission_library = models.BooleanField(
'Manage library',
help_text='Import new content, manage existing content',
default=False)
permission_settings = models.BooleanField(
'Manage instance-level settings',
default=False)
def __str__(self):
return self.username
def add_permission(self, codename):
p = Permission.objects.get(codename=codename)
self.user_permissions.add(p)
def get_permissions(self):
perms = {}
for p in PERMISSIONS:
v = self.is_superuser or getattr(self, 'permission_{}'.format(p))
perms[p] = v
return perms
def has_permissions(self, *perms):
permissions = self.get_permissions()
return all([permissions[p] for p in perms])
def get_absolute_url(self):
return reverse('users:detail', kwargs={'username': self.username})
......
from rest_framework.permissions import BasePermission
class HasUserPermission(BasePermission):
"""
Ensure the request user has the proper permissions.
Usage:
class MyView(APIView):
permission_classes = [HasUserPermission]
required_permissions = ['federation']
"""
def has_permission(self, request, view):
if not hasattr(request, 'user') or not request.user:
return False
if request.user.is_anonymous:
return False
return request.user.has_permissions(*view.required_permissions)
......@@ -55,16 +55,11 @@ class UserReadSerializer(serializers.ModelSerializer):
'is_superuser',
'permissions',
'date_joined',
'privacy_level'
'privacy_level',
]
def get_permissions(self, o):
perms = {}
for internal_codename, conf in o.relevant_permissions.items():
perms[conf['external_codename']] = {
'status': o.has_perm(internal_codename)
}
return perms
return o.get_permissions()
class PasswordResetSerializer(PRS):
......
import pytest
from funkwhale_api.common.management.commands import script
from funkwhale_api.common import scripts
@pytest.fixture
def command():
return script.Command()
@pytest.mark.parametrize('script_name', [
'django_permissions_to_user_permissions',
'test',
])
def test_script_command_list(command, script_name, mocker):
mocked = mocker.patch(
'funkwhale_api.common.scripts.{}.main'.format(script_name))
command.handle(script_name=script_name, interactive=False)
mocked.assert_called_once_with(
command, script_name=script_name, interactive=False)
def test_django_permissions_to_user_permissions(factories, command):
group = factories['auth.Group'](
perms=[
'federation.change_library'
]
)
user1 = factories['users.User'](
perms=[
'dynamic_preferences.change_globalpreferencemodel',
'music.add_importbatch',
]
)
user2 = factories['users.User'](
perms=[
'music.add_importbatch',
],
groups=[group]
)
scripts.django_permissions_to_user_permissions.main(command)
user1.refresh_from_db()
user2.refresh_from_db()
assert user1.permission_settings is True
assert user1.permission_library is True
assert user1.permission_federation is False
assert user2.permission_settings is False
assert user2.permission_library is True
assert user2.permission_federation is True
......@@ -14,6 +14,7 @@ from rest_framework.test import APIClient
from rest_framework.test import APIRequestFactory
from funkwhale_api.activity import record
from funkwhale_api.users.permissions import HasUserPermission
from funkwhale_api.taskapp import celery
......@@ -224,3 +225,11 @@ def authenticated_actor(factories, mocker):
'funkwhale_api.federation.authentication.SignatureAuthentication.authenticate_actor',
return_value=actor)
yield actor
@pytest.fixture
def assert_user_permission():
def inner(view, permissions):
assert HasUserPermission in view.permission_classes
assert set(view.required_permissions) == set(permissions)
return inner
......@@ -9,9 +9,18 @@ from funkwhale_api.federation import activity
from funkwhale_api.federation import models
from funkwhale_api.federation import serializers
from funkwhale_api.federation import utils
from funkwhale_api.federation import views
from funkwhale_api.federation import webfinger
@pytest.mark.parametrize('view,permissions', [
(views.LibraryViewSet, ['federation']),
(views.LibraryTrackViewSet, ['federation']),
])
def test_permissions(assert_user_permission, view, permissions):
assert_user_permission(view, permissions)
@pytest.mark.parametrize('system_actor', actors.SYSTEM_ACTORS.keys())
def test_instance_actors(system_actor, db, api_client):
actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance()
......
import pytest
from django.urls import reverse
from funkwhale_api.instance import views
@pytest.mark.parametrize('view,permissions', [
(views.AdminSettings, ['settings']),
])
def test_permissions(assert_user_permission, view, permissions):
assert_user_permission(view, permissions)
def test_nodeinfo_endpoint(db, api_client, mocker):
payload = {
......@@ -43,7 +54,8 @@ def test_admin_settings_restrict_access(db, logged_in_api_client, preferences):
def test_admin_settings_correct_permission(
db, logged_in_api_client, preferences):
user = logged_in_api_client.user
user.add_permission('change_globalpreferencemodel')
user.permission_settings = True
user.save()
url = reverse('api:v1:instance:admin-settings-list')
response = logged_in_api_client.get(url)
......