Merge branch '152-permissions' into 'develop'

Resolve "Permission management overhaul"

Closes #152

See merge request funkwhale/funkwhale!201
This commit is contained in:
Eliot Berriot 2018-05-19 09:38:32 +00:00
commit 5a2e7dbccd
32 changed files with 523 additions and 92 deletions

View File

@ -0,0 +1,66 @@
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

View File

@ -3,7 +3,7 @@ import operator
from django.conf import settings from django.conf import settings
from django.http import Http404 from django.http import Http404
from rest_framework.permissions import BasePermission, DjangoModelPermissions from rest_framework.permissions import BasePermission
from funkwhale_api.common import preferences from funkwhale_api.common import preferences
@ -16,17 +16,6 @@ class ConditionalAuthentication(BasePermission):
return True 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): class OwnerPermission(BasePermission):
""" """
Ensure the request user is the owner of the object. Ensure the request user is the owner of the object.

View File

@ -0,0 +1,2 @@
from . import django_permissions_to_user_permissions
from . import test

View File

@ -0,0 +1,29 @@
"""
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})

View File

@ -0,0 +1,8 @@
"""
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')

View File

@ -15,8 +15,8 @@ from rest_framework.serializers import ValidationError
from funkwhale_api.common import preferences from funkwhale_api.common import preferences
from funkwhale_api.common import utils as funkwhale_utils 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.music.models import TrackFile
from funkwhale_api.users.permissions import HasUserPermission
from . import activity from . import activity
from . import actors from . import actors
@ -187,16 +187,13 @@ class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet):
return response.Response(data) return response.Response(data)
class LibraryPermission(HasModelPermission):
model = models.Library
class LibraryViewSet( class LibraryViewSet(
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,
mixins.UpdateModelMixin, mixins.UpdateModelMixin,
mixins.ListModelMixin, mixins.ListModelMixin,
viewsets.GenericViewSet): viewsets.GenericViewSet):
permission_classes = [LibraryPermission] permission_classes = (HasUserPermission,)
required_permissions = ['federation']
queryset = models.Library.objects.all().select_related( queryset = models.Library.objects.all().select_related(
'actor', 'actor',
'follow', 'follow',
@ -291,7 +288,8 @@ class LibraryViewSet(
class LibraryTrackViewSet( class LibraryTrackViewSet(
mixins.ListModelMixin, mixins.ListModelMixin,
viewsets.GenericViewSet): viewsets.GenericViewSet):
permission_classes = [LibraryPermission] permission_classes = (HasUserPermission,)
required_permissions = ['federation']
queryset = models.LibraryTrack.objects.all().select_related( queryset = models.LibraryTrack.objects.all().select_related(
'library__actor', 'library__actor',
'library__follow', 'library__follow',

View File

@ -6,6 +6,7 @@ from dynamic_preferences.api import viewsets as preferences_viewsets
from dynamic_preferences.registries import global_preferences_registry from dynamic_preferences.registries import global_preferences_registry
from funkwhale_api.common import preferences from funkwhale_api.common import preferences
from funkwhale_api.users.permissions import HasUserPermission
from . import nodeinfo from . import nodeinfo
from . import stats from . import stats
@ -18,7 +19,8 @@ NODEINFO_2_CONTENT_TYPE = (
class AdminSettings(preferences_viewsets.GlobalPreferencesViewSet): class AdminSettings(preferences_viewsets.GlobalPreferencesViewSet):
pagination_class = None pagination_class = None
permission_classes = (HasUserPermission,)
required_permissions = ['settings']
class InstanceSettings(views.APIView): class InstanceSettings(views.APIView):
permission_classes = [] permission_classes = []

View File

@ -25,8 +25,8 @@ from rest_framework import permissions
from musicbrainzngs import ResponseError from musicbrainzngs import ResponseError
from funkwhale_api.common import utils as funkwhale_utils from funkwhale_api.common import utils as funkwhale_utils
from funkwhale_api.common.permissions import ( from funkwhale_api.common.permissions import ConditionalAuthentication
ConditionalAuthentication, HasModelPermission) from funkwhale_api.users.permissions import HasUserPermission
from taggit.models import Tag from taggit.models import Tag
from funkwhale_api.federation import actors from funkwhale_api.federation import actors
from funkwhale_api.federation.authentication import SignatureAuthentication from funkwhale_api.federation.authentication import SignatureAuthentication
@ -107,25 +107,22 @@ class ImportBatchViewSet(
.annotate(job_count=Count('jobs')) .annotate(job_count=Count('jobs'))
) )
serializer_class = serializers.ImportBatchSerializer serializer_class = serializers.ImportBatchSerializer
permission_classes = (permissions.DjangoModelPermissions, ) permission_classes = (HasUserPermission,)
required_permissions = ['library']
filter_class = filters.ImportBatchFilter filter_class = filters.ImportBatchFilter
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(submitted_by=self.request.user) 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( class ImportJobViewSet(
mixins.CreateModelMixin, mixins.CreateModelMixin,
mixins.ListModelMixin, mixins.ListModelMixin,
viewsets.GenericViewSet): viewsets.GenericViewSet):
queryset = (models.ImportJob.objects.all().select_related()) queryset = (models.ImportJob.objects.all().select_related())
serializer_class = serializers.ImportJobSerializer serializer_class = serializers.ImportJobSerializer
permission_classes = (ImportJobPermission, ) permission_classes = (HasUserPermission,)
required_permissions = ['library']
filter_class = filters.ImportJobFilter filter_class = filters.ImportJobFilter
@list_route(methods=['get']) @list_route(methods=['get'])
@ -442,7 +439,8 @@ class Search(views.APIView):
class SubmitViewSet(viewsets.ViewSet): class SubmitViewSet(viewsets.ViewSet):
queryset = models.ImportBatch.objects.none() queryset = models.ImportBatch.objects.none()
permission_classes = (permissions.DjangoModelPermissions, ) permission_classes = (HasUserPermission,)
required_permissions = ['library']
@list_route(methods=['post']) @list_route(methods=['post'])
@transaction.non_atomic_requests @transaction.non_atomic_requests

View File

@ -5,6 +5,7 @@ from django import forms
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as AuthUserAdmin from django.contrib.auth.admin import UserAdmin as AuthUserAdmin
from django.contrib.auth.forms import UserChangeForm, UserCreationForm from django.contrib.auth.forms import UserChangeForm, UserCreationForm
from django.utils.translation import ugettext_lazy as _
from .models import User from .models import User
@ -41,8 +42,33 @@ class UserAdmin(AuthUserAdmin):
'email', 'email',
'date_joined', 'date_joined',
'last_login', 'last_login',
'privacy_level', 'is_staff',
'is_superuser',
] ]
list_filter = [ list_filter = [
'is_superuser',
'is_staff',
'privacy_level', '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',
)})
)

View File

@ -1,15 +1,41 @@
import factory import factory
from funkwhale_api.factories import registry from funkwhale_api.factories import registry, ManyToManyFromList
from django.contrib.auth.models import Permission 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 @registry.register
class UserFactory(factory.django.DjangoModelFactory): class UserFactory(factory.django.DjangoModelFactory):
username = factory.Sequence(lambda n: 'user-{0}'.format(n)) username = factory.Sequence(lambda n: 'user-{0}'.format(n))
email = factory.Sequence(lambda n: 'user-{0}@example.com'.format(n)) email = factory.Sequence(lambda n: 'user-{0}@example.com'.format(n))
password = factory.PostGenerationMethodCall('set_password', 'test') password = factory.PostGenerationMethodCall('set_password', 'test')
subsonic_api_token = None subsonic_api_token = None
groups = ManyToManyFromList('groups')
class Meta: class Meta:
model = 'users.User' model = 'users.User'

View File

@ -0,0 +1,28 @@
# 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),
),
]

View File

@ -19,6 +19,13 @@ def get_token():
return binascii.b2a_hex(os.urandom(15)).decode('utf-8') return binascii.b2a_hex(os.urandom(15)).decode('utf-8')
PERMISSIONS = [
'federation',
'library',
'settings',
]
@python_2_unicode_compatible @python_2_unicode_compatible
class User(AbstractUser): class User(AbstractUser):
@ -28,20 +35,6 @@ class User(AbstractUser):
# updated on logout or password change, to invalidate JWT # updated on logout or password change, to invalidate JWT
secret_key = models.UUIDField(default=uuid.uuid4, null=True) 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() privacy_level = fields.get_privacy_field()
# Unfortunately, Subsonic API assumes a MD5/password authentication # Unfortunately, Subsonic API assumes a MD5/password authentication
@ -52,12 +45,32 @@ class User(AbstractUser):
subsonic_api_token = models.CharField( subsonic_api_token = models.CharField(
blank=True, null=True, max_length=255) 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): def __str__(self):
return self.username return self.username
def add_permission(self, codename): def get_permissions(self):
p = Permission.objects.get(codename=codename) perms = {}
self.user_permissions.add(p) 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): def get_absolute_url(self):
return reverse('users:detail', kwargs={'username': self.username}) return reverse('users:detail', kwargs={'username': self.username})

View File

@ -0,0 +1,19 @@
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)

View File

@ -55,16 +55,11 @@ class UserReadSerializer(serializers.ModelSerializer):
'is_superuser', 'is_superuser',
'permissions', 'permissions',
'date_joined', 'date_joined',
'privacy_level' 'privacy_level',
] ]
def get_permissions(self, o): def get_permissions(self, o):
perms = {} return o.get_permissions()
for internal_codename, conf in o.relevant_permissions.items():
perms[conf['external_codename']] = {
'status': o.has_perm(internal_codename)
}
return perms
class PasswordResetSerializer(PRS): class PasswordResetSerializer(PRS):

View File

@ -0,0 +1,56 @@
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

View File

@ -14,6 +14,7 @@ from rest_framework.test import APIClient
from rest_framework.test import APIRequestFactory from rest_framework.test import APIRequestFactory
from funkwhale_api.activity import record from funkwhale_api.activity import record
from funkwhale_api.users.permissions import HasUserPermission
from funkwhale_api.taskapp import celery from funkwhale_api.taskapp import celery
@ -224,3 +225,11 @@ def authenticated_actor(factories, mocker):
'funkwhale_api.federation.authentication.SignatureAuthentication.authenticate_actor', 'funkwhale_api.federation.authentication.SignatureAuthentication.authenticate_actor',
return_value=actor) return_value=actor)
yield 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

View File

@ -9,9 +9,18 @@ from funkwhale_api.federation import activity
from funkwhale_api.federation import models from funkwhale_api.federation import models
from funkwhale_api.federation import serializers from funkwhale_api.federation import serializers
from funkwhale_api.federation import utils from funkwhale_api.federation import utils
from funkwhale_api.federation import views
from funkwhale_api.federation import webfinger 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()) @pytest.mark.parametrize('system_actor', actors.SYSTEM_ACTORS.keys())
def test_instance_actors(system_actor, db, api_client): def test_instance_actors(system_actor, db, api_client):
actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance() actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance()

View File

@ -1,5 +1,16 @@
import pytest
from django.urls import reverse 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): def test_nodeinfo_endpoint(db, api_client, mocker):
payload = { payload = {
@ -43,7 +54,8 @@ def test_admin_settings_restrict_access(db, logged_in_api_client, preferences):
def test_admin_settings_correct_permission( def test_admin_settings_correct_permission(
db, logged_in_api_client, preferences): db, logged_in_api_client, preferences):
user = logged_in_api_client.user 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') url = reverse('api:v1:instance:admin-settings-list')
response = logged_in_api_client.get(url) response = logged_in_api_client.get(url)

View File

@ -8,6 +8,14 @@ from funkwhale_api.music import views
from funkwhale_api.federation import actors from funkwhale_api.federation import actors
@pytest.mark.parametrize('view,permissions', [
(views.ImportBatchViewSet, ['library']),
(views.ImportJobViewSet, ['library']),
])
def test_permissions(assert_user_permission, view, permissions):
assert_user_permission(view, permissions)
@pytest.mark.parametrize('param,expected', [ @pytest.mark.parametrize('param,expected', [
('true', 'full'), ('true', 'full'),
('false', 'empty'), ('false', 'empty'),

View File

@ -1,3 +1,7 @@
import pytest
from funkwhale_api.users import models
def test__str__(factories): def test__str__(factories):
user = factories['users.User'](username='hello') user = factories['users.User'](username='hello')
@ -16,3 +20,33 @@ def test_changing_password_updates_subsonic_api_token(factories):
assert user.subsonic_api_token is not None assert user.subsonic_api_token is not None
assert user.subsonic_api_token != 'test' assert user.subsonic_api_token != 'test'
def test_get_permissions_superuser(factories):
user = factories['users.User'](is_superuser=True)
perms = user.get_permissions()
for p in models.PERMISSIONS:
assert perms[p] is True
def test_get_permissions_regular(factories):
user = factories['users.User'](permission_library=True)
perms = user.get_permissions()
for p in models.PERMISSIONS:
if p == 'library':
assert perms[p] is True
else:
assert perms[p] is False
@pytest.mark.parametrize('args,perms,expected', [
({'is_superuser': True}, ['federation', 'library'], True),
({'is_superuser': False}, ['federation'], False),
({'permission_library': True}, ['library'], True),
({'permission_library': True}, ['library', 'federation'], False),
])
def test_has_permissions(args, perms, expected, factories):
user = factories['users.User'](**args)
assert user.has_permissions(*perms) is expected

View File

@ -0,0 +1,56 @@
import pytest
from rest_framework.views import APIView
from funkwhale_api.users import permissions
def test_has_user_permission_no_user(api_request):
view = APIView.as_view()
permission = permissions.HasUserPermission()
request = api_request.get('/')
assert permission.has_permission(request, view) is False
def test_has_user_permission_anonymous(anonymous_user, api_request):
view = APIView.as_view()
permission = permissions.HasUserPermission()
request = api_request.get('/')
setattr(request, 'user', anonymous_user)
assert permission.has_permission(request, view) is False
@pytest.mark.parametrize('value', [True, False])
def test_has_user_permission_logged_in_single(value, factories, api_request):
user = factories['users.User'](permission_federation=value)
class View(APIView):
required_permissions = ['federation']
view = View()
permission = permissions.HasUserPermission()
request = api_request.get('/')
setattr(request, 'user', user)
result = permission.has_permission(request, view)
assert result == user.has_permissions('federation') == value
@pytest.mark.parametrize('federation,library,expected', [
(True, False, False),
(False, True, False),
(False, False, False),
(True, True, True),
])
def test_has_user_permission_logged_in_single(
federation, library, expected, factories, api_request):
user = factories['users.User'](
permission_federation=federation,
permission_library=library,
)
class View(APIView):
required_permissions = ['federation', 'library']
view = View()
permission = permissions.HasUserPermission()
request = api_request.get('/')
setattr(request, 'user', user)
result = permission.has_permission(request, view)
assert result == user.has_permissions('federation', 'library') == expected

View File

@ -53,33 +53,24 @@ def test_can_disable_registration_view(preferences, client, db):
assert response.status_code == 403 assert response.status_code == 403
def test_can_fetch_data_from_api(client, factories): def test_can_fetch_data_from_api(api_client, factories):
url = reverse('api:v1:users:users-me') url = reverse('api:v1:users:users-me')
response = client.get(url) response = api_client.get(url)
# login required # login required
assert response.status_code == 401 assert response.status_code == 401
user = factories['users.User']( user = factories['users.User'](
is_staff=True, permission_library=True
perms=[
'music.add_importbatch',
'dynamic_preferences.change_globalpreferencemodel',
]
) )
assert user.has_perm('music.add_importbatch') api_client.login(username=user.username, password='test')
client.login(username=user.username, password='test') response = api_client.get(url)
response = client.get(url)
assert response.status_code == 200 assert response.status_code == 200
assert response.data['username'] == user.username
payload = json.loads(response.content.decode('utf-8')) assert response.data['is_staff'] == user.is_staff
assert response.data['is_superuser'] == user.is_superuser
assert payload['username'] == user.username assert response.data['email'] == user.email
assert payload['is_staff'] == user.is_staff assert response.data['name'] == user.name
assert payload['is_superuser'] == user.is_superuser assert response.data['permissions'] == user.get_permissions()
assert payload['email'] == user.email
assert payload['name'] == user.name
assert payload['permissions']['import.launch']['status']
assert payload['permissions']['settings.change']['status']
def test_can_get_token_via_api(client, factories): def test_can_get_token_via_api(client, factories):
@ -202,6 +193,8 @@ def test_user_can_get_new_subsonic_token(logged_in_api_client):
assert response.data == { assert response.data == {
'subsonic_api_token': 'test' 'subsonic_api_token': 'test'
} }
def test_user_can_request_new_subsonic_token(logged_in_api_client): def test_user_can_request_new_subsonic_token(logged_in_api_client):
user = logged_in_api_client.user user = logged_in_api_client.user
user.subsonic_api_token = 'test' user.subsonic_api_token = 'test'

View File

@ -0,0 +1,32 @@
Simpler permission system (#152)
Simpler permission system
=========================
Starting from this release, the permission system is much simpler. Up until now,
we were using Django's built-in permission system, which was working, but also
quite complex to deal with.
The new implementation relies on simpler logic, which will make integration
on the front-end in upcoming releases faster and easier.
If you have manually given permissions to users on your instance,
you can migrate those to the new system.
On docker setups:
.. code-block:: shell
docker-compose run --rm api python manage.py script django_permissions_to_user_permissions --no-input
On non-docker setups:
.. code-block:: shell
# in your virtualenv
python api/manage.py script django_permissions_to_user_permissions --no-input
There is still no dedicated interface to manage user permissions, but you
can use the admin interface at ``/api/admin/users/user/`` for that purpose in
the meantime.

View File

@ -117,3 +117,28 @@ Then, the value of :ref:`setting-MUSIC_DIRECTORY_SERVE_PATH` should be
On non-docker setup, you don't need to configure this setting. On non-docker setup, you don't need to configure this setting.
.. note:: This path should not include any trailing slash .. note:: This path should not include any trailing slash
User permissions
----------------
Funkwhale's permission model works as follows:
- Anonymous users cannot do anything unless configured specifically
- Logged-in users can use the application, but cannot do things that affect
the whole instance
- Superusers can do anything
To make things more granular and allow some delegation of responsability,
superusers can grant specific permissions to specific users. Available
permissions are:
- **Manage instance-level settings**: users with this permission can edit instance
settings as described in :ref:`instance-settings`
- **Manage library**: users with this permission can import new music in the
instance
- **Manage library federation**: users with this permission can ask to federate with
other instances, and accept/deny federation requests from other intances
There is no dedicated interface to manage users permissions, but superusers
can login on the Django's admin at ``/api/admin/`` and grant permissions
to users at ``/api/admin/users/user/``.

View File

@ -15,7 +15,7 @@
</p> </p>
<router-link <router-link
class="ui button" class="ui button"
v-if="$store.state.auth.availablePermissions['settings.change']" v-if="$store.state.auth.availablePermissions['settings']"
:to="{path: '/manage/settings', hash: 'instance'}"> :to="{path: '/manage/settings', hash: 'instance'}">
<i class="pencil icon"></i>{{ $t('Edit instance info') }} <i class="pencil icon"></i>{{ $t('Edit instance info') }}
</router-link> </router-link>

View File

@ -60,7 +60,7 @@
<div class="menu"> <div class="menu">
<router-link <router-link
class="item" class="item"
v-if="$store.state.auth.availablePermissions['import.launch']" v-if="$store.state.auth.availablePermissions['library']"
:to="{name: 'library.requests', query: {status: 'pending' }}"> :to="{name: 'library.requests', query: {status: 'pending' }}">
<i class="download icon"></i>{{ $t('Import requests') }} <i class="download icon"></i>{{ $t('Import requests') }}
<div <div
@ -70,7 +70,7 @@
</router-link> </router-link>
<router-link <router-link
class="item" class="item"
v-if="$store.state.auth.availablePermissions['federation.manage']" v-if="$store.state.auth.availablePermissions['federation']"
:to="{path: '/manage/federation/libraries'}"> :to="{path: '/manage/federation/libraries'}">
<i class="sitemap icon"></i>{{ $t('Federation') }} <i class="sitemap icon"></i>{{ $t('Federation') }}
<div <div
@ -80,7 +80,7 @@
</router-link> </router-link>
<router-link <router-link
class="item" class="item"
v-if="$store.state.auth.availablePermissions['settings.change']" v-if="$store.state.auth.availablePermissions['settings']"
:to="{path: '/manage/settings'}"> :to="{path: '/manage/settings'}">
<i class="settings icon"></i>{{ $t('Settings') }} <i class="settings icon"></i>{{ $t('Settings') }}
</router-link> </router-link>
@ -192,8 +192,8 @@ export default {
}), }),
showAdmin () { showAdmin () {
let adminPermissions = [ let adminPermissions = [
this.$store.state.auth.availablePermissions['federation.manage'], this.$store.state.auth.availablePermissions['federation'],
this.$store.state.auth.availablePermissions['import.launch'] this.$store.state.auth.availablePermissions['library']
] ]
return adminPermissions.filter(e => { return adminPermissions.filter(e => {
return e return e
@ -209,7 +209,7 @@ export default {
this.fetchFederationImportRequestsCount() this.fetchFederationImportRequestsCount()
}, },
fetchFederationNotificationsCount () { fetchFederationNotificationsCount () {
if (!this.$store.state.auth.availablePermissions['federation.manage']) { if (!this.$store.state.auth.availablePermissions['federation']) {
return return
} }
let self = this let self = this
@ -218,7 +218,7 @@ export default {
}) })
}, },
fetchFederationImportRequestsCount () { fetchFederationImportRequestsCount () {
if (!this.$store.state.auth.availablePermissions['import.launch']) { if (!this.$store.state.auth.availablePermissions['library']) {
return return
} }
let self = this let self = this

View File

@ -13,10 +13,10 @@
exact> exact>
<i18next path="Requests"/> <i18next path="Requests"/>
</router-link> </router-link>
<router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/launch" exact> <router-link v-if="$store.state.auth.availablePermissions['library']" class="ui item" to="/library/import/launch" exact>
<i18next path="Import"/> <i18next path="Import"/>
</router-link> </router-link>
<router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/batches"> <router-link v-if="$store.state.auth.availablePermissions['library']" class="ui item" to="/library/import/batches">
<i18next path="Import batches"/> <i18next path="Import batches"/>
</router-link> </router-link>
</div> </div>

View File

@ -22,7 +22,7 @@
</span> </span>
<button <button
@click="createImport" @click="createImport"
v-if="request.status === 'pending' && importAction && $store.state.auth.availablePermissions['import.launch']" v-if="request.status === 'pending' && importAction && $store.state.auth.availablePermissions['library']"
class="ui mini basic green right floated button">{{ $t('Create import') }}</button> class="ui mini basic green right floated button">{{ $t('Create import') }}</button>
</div> </div>

View File

@ -112,7 +112,7 @@ export default {
dispatch('playlists/fetchOwn', null, {root: true}) dispatch('playlists/fetchOwn', null, {root: true})
Object.keys(data.permissions).forEach(function (key) { Object.keys(data.permissions).forEach(function (key) {
// this makes it easier to check for permissions in templates // this makes it easier to check for permissions in templates
commit('permission', {key, status: data.permissions[String(key)].status}) commit('permission', {key, status: data.permissions[String(key)]})
}) })
return response.data return response.data
}, (response) => { }, (response) => {

View File

@ -164,9 +164,7 @@ describe('store/auth', () => {
const profile = { const profile = {
username: 'bob', username: 'bob',
permissions: { permissions: {
admin: { admin: true
status: true
}
} }
} }
moxios.stubRequest('users/users/me/', { moxios.stubRequest('users/users/me/', {