diff --git a/api/funkwhale_api/users/migrations/0006_auto_20180517_2324.py b/api/funkwhale_api/users/migrations/0006_auto_20180517_2324.py new file mode 100644 index 0000000000000000000000000000000000000000..7c9ab0fadc99016e8a62f609ace117ea0b941965 --- /dev/null +++ b/api/funkwhale_api/users/migrations/0006_auto_20180517_2324.py @@ -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), + ), + ] diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index 8273507c49bb23f1986b6b691fe90cc1fc8fea45..1de23092aedcb1063da2f970a0b6820610732c94 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -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,24 @@ class User(AbstractUser): subsonic_api_token = models.CharField( blank=True, null=True, max_length=255) + # permissions + permission_federation = models.BooleanField(default=False) + permission_library = models.BooleanField(default=False) + permission_settings = models.BooleanField(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}) diff --git a/api/funkwhale_api/users/permissions.py b/api/funkwhale_api/users/permissions.py new file mode 100644 index 0000000000000000000000000000000000000000..2ff49ff3fa6661aecd0616fdb87b5b43f89a95c2 --- /dev/null +++ b/api/funkwhale_api/users/permissions.py @@ -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) diff --git a/api/tests/users/test_models.py b/api/tests/users/test_models.py index c7cd12e9e3ba19a457fa0d66d68e6b3679925c84..49199e0a781b990268431f109ac0c8804f8391ea 100644 --- a/api/tests/users/test_models.py +++ b/api/tests/users/test_models.py @@ -1,3 +1,7 @@ +import pytest + +from funkwhale_api.users import models + def test__str__(factories): 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 != '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 diff --git a/api/tests/users/test_permissions.py b/api/tests/users/test_permissions.py new file mode 100644 index 0000000000000000000000000000000000000000..1564c761db59239eee5169bffd6ee92c2c148301 --- /dev/null +++ b/api/tests/users/test_permissions.py @@ -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