Merge branch '152-permissions' into 'develop'
Resolve "Permission management overhaul" Closes #152 See merge request funkwhale/funkwhale!201
This commit is contained in:
commit
5a2e7dbccd
|
@ -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
|
|
@ -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.
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
from . import django_permissions_to_user_permissions
|
||||||
|
from . import test
|
|
@ -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})
|
|
@ -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')
|
|
@ -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',
|
||||||
|
|
|
@ -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 = []
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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',
|
||||||
|
)})
|
||||||
|
)
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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})
|
||||||
|
|
|
@ -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)
|
|
@ -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):
|
||||||
|
|
|
@ -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
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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'),
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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'
|
||||||
|
|
|
@ -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.
|
|
@ -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/``.
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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/', {
|
||||||
|
|
Loading…
Reference in New Issue